diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 11e07df..7ae90be 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -7,6 +7,7 @@ import { logout, getMe, getStatus, + changePassword, listVolumes, createVolume, testConnection, @@ -44,6 +45,8 @@ import type { LogoutResponse, GetMeData, GetStatusData, + ChangePasswordData, + ChangePasswordResponse, ListVolumesData, CreateVolumeData, CreateVolumeResponse, @@ -283,6 +286,46 @@ export const getStatusOptions = (options?: Options) => { }); }; +export const changePasswordQueryKey = (options?: Options) => + createQueryKey("changePassword", options); + +/** + * Change current user password + */ +export const changePasswordOptions = (options?: Options) => { + 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>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await changePassword({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const listVolumesQueryKey = (options?: Options) => createQueryKey("listVolumes", options); /** diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 0b4825d..874933c 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -14,6 +14,9 @@ import type { GetMeResponses, GetStatusData, GetStatusResponses, + ChangePasswordData, + ChangePasswordResponses, + ChangePasswordErrors, ListVolumesData, ListVolumesResponses, CreateVolumeData, @@ -148,6 +151,22 @@ export const getStatus = (options?: Option }); }; +/** + * Change current user password + */ +export const changePassword = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).post({ + url: "/api/v1/auth/change-password", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); +}; + /** * List all volumes */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 5b43ce3..a768a31 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -125,6 +125,39 @@ export type 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 = { body?: never; path?: never; diff --git a/apps/client/app/components/app-sidebar.tsx b/apps/client/app/components/app-sidebar.tsx index 015a35b..78bc14c 100644 --- a/apps/client/app/components/app-sidebar.tsx +++ b/apps/client/app/components/app-sidebar.tsx @@ -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 { Sidebar, @@ -30,6 +30,11 @@ const items = [ url: "/backups", icon: CalendarClock, }, + { + title: "Settings", + url: "/settings", + icon: Settings, + }, ]; export function AppSidebar() { diff --git a/apps/client/app/modules/settings/routes/settings.tsx b/apps/client/app/modules/settings/routes/settings.tsx new file mode 100644 index 0000000..9699a01 --- /dev/null +++ b/apps/client/app/modules/settings/routes/settings.tsx @@ -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 ( + +
+ + + Account Information + + Your account details +
+ +
+ + +
+
+ +
+ + + Change Password + + Update your password to keep your account secure +
+ +
+
+ + setCurrentPassword(e.target.value)} + className="max-w-md" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="max-w-md" + required + minLength={8} + /> +

Must be at least 8 characters long

+
+
+ + setConfirmPassword(e.target.value)} + className="max-w-md" + required + minLength={8} + /> +
+ +
+
+
+ ); +} diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 342e54b..21b96d8 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -13,5 +13,6 @@ export default [ route("repositories", "./modules/repositories/routes/repositories.tsx"), route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"), route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"), + route("settings", "./modules/settings/routes/settings.tsx"), ]), ] satisfies RouteConfig; diff --git a/apps/server/src/modules/auth/auth.controller.ts b/apps/server/src/modules/auth/auth.controller.ts index b0bed52..23113b9 100644 --- a/apps/server/src/modules/auth/auth.controller.ts +++ b/apps/server/src/modules/auth/auth.controller.ts @@ -3,6 +3,8 @@ import { validator } from "hono-openapi"; import { Hono } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { + changePasswordBodySchema, + changePasswordDto, getMeDto, getStatusDto, loginBodySchema, @@ -10,6 +12,7 @@ import { logoutDto, registerBodySchema, registerDto, + type ChangePasswordDto, type GetMeDto, type GetStatusDto, type LoginDto, @@ -100,4 +103,27 @@ export const authController = new Hono() .get("/status", getStatusDto, async (c) => { const hasUsers = await authService.hasUsers(); return c.json({ hasUsers }); + }) + .post("/change-password", changePasswordDto, validator("json", changePasswordBodySchema), async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ 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({ success: true, message: "Password changed successfully" }); + } catch (error) { + return c.json({ success: false, message: toMessage(error) }, 400); + } }); diff --git a/apps/server/src/modules/auth/auth.dto.ts b/apps/server/src/modules/auth/auth.dto.ts index 47e13fd..1d70765 100644 --- a/apps/server/src/modules/auth/auth.dto.ts +++ b/apps/server/src/modules/auth/auth.dto.ts @@ -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 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 RegisterBody = typeof registerBodySchema.infer; +export type ChangePasswordBody = typeof changePasswordBodySchema.infer; diff --git a/apps/server/src/modules/auth/auth.service.ts b/apps/server/src/modules/auth/auth.service.ts index 3c65c00..d56ece3 100644 --- a/apps/server/src/modules/auth/auth.service.ts +++ b/apps/server/src/modules/auth/auth.service.ts @@ -134,6 +134,33 @@ export class AuthService { const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1); 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(); diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 77c351d..74403b3 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -334,6 +334,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: args.push("--prune"); args.push("--json"); + await $`restic unlock --repo ${repoUrl}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) {