feat(settings): change password

This commit is contained in:
Nicolas Meienberger
2025-11-08 09:47:10 +01:00
parent 195aea052e
commit 418369c4ad
10 changed files with 333 additions and 7 deletions

View File

@@ -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<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);
/**

View File

@@ -14,6 +14,9 @@ import type {
GetMeResponses,
GetStatusData,
GetStatusResponses,
ChangePasswordData,
ChangePasswordResponses,
ChangePasswordErrors,
ListVolumesData,
ListVolumesResponses,
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
*/

View File

@@ -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;

View File

@@ -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() {

View 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>
);
}

View File

@@ -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;