mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(settings): change password
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
getMe,
|
getMe,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
changePassword,
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
testConnection,
|
testConnection,
|
||||||
@@ -44,6 +45,8 @@ import type {
|
|||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
GetMeData,
|
GetMeData,
|
||||||
GetStatusData,
|
GetStatusData,
|
||||||
|
ChangePasswordData,
|
||||||
|
ChangePasswordResponse,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
CreateVolumeResponse,
|
CreateVolumeResponse,
|
||||||
@@ -283,6 +286,46 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const changePasswordQueryKey = (options?: Options<ChangePasswordData>) =>
|
||||||
|
createQueryKey("changePassword", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePasswordOptions = (options?: Options<ChangePasswordData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await changePassword({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: changePasswordQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePasswordMutation = (
|
||||||
|
options?: Partial<Options<ChangePasswordData>>,
|
||||||
|
): UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await changePassword({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import type {
|
|||||||
GetMeResponses,
|
GetMeResponses,
|
||||||
GetStatusData,
|
GetStatusData,
|
||||||
GetStatusResponses,
|
GetStatusResponses,
|
||||||
|
ChangePasswordData,
|
||||||
|
ChangePasswordResponses,
|
||||||
|
ChangePasswordErrors,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
ListVolumesResponses,
|
ListVolumesResponses,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
@@ -148,6 +151,22 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePassword = <ThrowOnError extends boolean = false>(
|
||||||
|
options?: Options<ChangePasswordData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<ChangePasswordResponses, ChangePasswordErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/change-password",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -125,6 +125,39 @@ export type GetStatusResponses = {
|
|||||||
|
|
||||||
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
|
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
|
||||||
|
|
||||||
|
export type ChangePasswordData = {
|
||||||
|
body?: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/change-password";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordErrors = {
|
||||||
|
/**
|
||||||
|
* Invalid current password or validation error
|
||||||
|
*/
|
||||||
|
400: unknown;
|
||||||
|
/**
|
||||||
|
* Not authenticated
|
||||||
|
*/
|
||||||
|
401: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordResponses = {
|
||||||
|
/**
|
||||||
|
* Password changed successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordResponse = ChangePasswordResponses[keyof ChangePasswordResponses];
|
||||||
|
|
||||||
export type ListVolumesData = {
|
export type ListVolumesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react";
|
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -30,6 +30,11 @@ const items = [
|
|||||||
url: "/backups",
|
url: "/backups",
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
148
apps/client/app/modules/settings/routes/settings.tsx
Normal file
148
apps/client/app/modules/settings/routes/settings.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { KeyRound, User } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { changePasswordMutation, logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { appContext } from "~/context";
|
||||||
|
import type { Route } from "./+types/settings";
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Settings - Ironmount" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Manage your account settings and preferences.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
|
const ctx = context.get(appContext);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const logout = useMutation({
|
||||||
|
...logoutMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePassword = useMutation({
|
||||||
|
...changePasswordMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
toast.success("Password changed successfully. You will be logged out.");
|
||||||
|
setTimeout(() => {
|
||||||
|
logout.mutate({});
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to change password", { description: data.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to change password", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChangePassword = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword.mutate({
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
|
<div className="border-b border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="size-5" />
|
||||||
|
Account Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-5" />
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" loading={changePassword.isPending} className="mt-4">
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,5 +13,6 @@ export default [
|
|||||||
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
||||||
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
route("settings", "./modules/settings/routes/settings.tsx"),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { validator } from "hono-openapi";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
import {
|
import {
|
||||||
|
changePasswordBodySchema,
|
||||||
|
changePasswordDto,
|
||||||
getMeDto,
|
getMeDto,
|
||||||
getStatusDto,
|
getStatusDto,
|
||||||
loginBodySchema,
|
loginBodySchema,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
logoutDto,
|
logoutDto,
|
||||||
registerBodySchema,
|
registerBodySchema,
|
||||||
registerDto,
|
registerDto,
|
||||||
|
type ChangePasswordDto,
|
||||||
type GetMeDto,
|
type GetMeDto,
|
||||||
type GetStatusDto,
|
type GetStatusDto,
|
||||||
type LoginDto,
|
type LoginDto,
|
||||||
@@ -100,4 +103,27 @@ export const authController = new Hono()
|
|||||||
.get("/status", getStatusDto, async (c) => {
|
.get("/status", getStatusDto, async (c) => {
|
||||||
const hasUsers = await authService.hasUsers();
|
const hasUsers = await authService.hasUsers();
|
||||||
return c.json<GetStatusDto>({ hasUsers });
|
return c.json<GetStatusDto>({ hasUsers });
|
||||||
|
})
|
||||||
|
.post("/change-password", changePasswordDto, validator("json", changePasswordBodySchema), async (c) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authService.verifySession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.changePassword(session.user.id, body.currentPassword, body.newPassword);
|
||||||
|
return c.json<ChangePasswordDto>({ success: true, message: "Password changed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ export const loginDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
401: {
|
|
||||||
description: "Invalid credentials",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +52,6 @@ export const registerDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
|
||||||
description: "Invalid request or username already exists",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,5 +119,34 @@ export const getStatusDto = describeRoute({
|
|||||||
|
|
||||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
export type GetStatusDto = typeof statusResponseSchema.infer;
|
||||||
|
|
||||||
|
export const changePasswordBodySchema = type({
|
||||||
|
currentPassword: "string>0",
|
||||||
|
newPassword: "string>7",
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordResponseSchema = type({
|
||||||
|
success: "boolean",
|
||||||
|
message: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changePasswordDto = describeRoute({
|
||||||
|
description: "Change current user password",
|
||||||
|
operationId: "changePassword",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Password changed successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(changePasswordResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
||||||
|
|
||||||
export type LoginBody = typeof loginBodySchema.infer;
|
export type LoginBody = typeof loginBodySchema.infer;
|
||||||
export type RegisterBody = typeof registerBodySchema.infer;
|
export type RegisterBody = typeof registerBodySchema.infer;
|
||||||
|
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
||||||
|
|||||||
@@ -134,6 +134,33 @@ export class AuthService {
|
|||||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||||
return !!user;
|
return !!user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password for a user
|
||||||
|
*/
|
||||||
|
async changePassword(userId: number, currentPassword: string, newPassword: string) {
|
||||||
|
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Current password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await Bun.password.hash(newPassword, {
|
||||||
|
algorithm: "argon2id",
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
|
||||||
|
|
||||||
|
logger.info(`Password changed for user: ${user.username}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService();
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
|
await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user