diff --git a/frontend/src/components/UserSettings/AccountSecurityCard.tsx b/frontend/src/components/UserSettings/AccountSecurityCard.tsx new file mode 100644 index 0000000000..f36419bc19 --- /dev/null +++ b/frontend/src/components/UserSettings/AccountSecurityCard.tsx @@ -0,0 +1,93 @@ +import { Key } from "lucide-react" +import { useState } from "react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import useAuth from "@/hooks/useAuth" +import ChangePassword from "./ChangePassword" + +export function AccountSecurityCard() { + const { user } = useAuth() + const [changePasswordOpen, setChangePasswordOpen] = useState(false) + + return ( + + + Account + + Manage your account status and password. + + + +
+
+ +

+ Your account is currently active. +

+
+ + {user?.is_active ? "Active" : "Inactive"} + +
+ + + +
+
+ +

+ Update your password. +

+
+ + + + + + + Change password + + Enter your current password and choose a new one. + + + setChangePasswordOpen(false)} + embedded + /> + + +
+
+
+ ) +} diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx index aeb8537028..271336161d 100644 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -7,6 +7,7 @@ import { type UpdatePassword, UsersService } from "@/client" import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -17,32 +18,50 @@ import { PasswordInput } from "@/components/ui/password-input" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" -const formSchema = z +const PASSWORD_MIN_LENGTH = 8 + +const passwordSchema = z + .string() + .min(1, { message: "Password is required" }) + .min(PASSWORD_MIN_LENGTH, { + message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + }) + +const changePasswordSchema = z .object({ - current_password: z - .string() - .min(1, { message: "Password is required" }) - .min(8, { message: "Password must be at least 8 characters" }), - new_password: z - .string() - .min(1, { message: "Password is required" }) - .min(8, { message: "Password must be at least 8 characters" }), + current_password: passwordSchema, + new_password: passwordSchema, confirm_password: z .string() - .min(1, { message: "Password confirmation is required" }), + .min(1, { message: "Please confirm your new password" }), }) .refine((data) => data.new_password === data.confirm_password, { - message: "The passwords don't match", + message: "Passwords do not match", path: ["confirm_password"], }) + .refine((data) => data.new_password !== data.current_password, { + message: "New password cannot be the same as the current one", + path: ["new_password"], + }) + +type ChangePasswordFormData = z.infer -type FormData = z.infer +export interface ChangePasswordProps { + /** Called after password is updated successfully (e.g. to close a dialog) */ + onSuccess?: () => void + /** When true, omits the heading and adjusts layout for use inside a dialog */ + embedded?: boolean +} -const ChangePassword = () => { +export default function ChangePassword({ + onSuccess, + embedded, +}: ChangePasswordProps) { const { showSuccessToast, showErrorToast } = useCustomToast() - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onSubmit", + + const form = useForm({ + resolver: zodResolver(changePasswordSchema), + mode: "onBlur", criteriaMode: "all", defaultValues: { current_password: "", @@ -57,33 +76,42 @@ const ChangePassword = () => { onSuccess: () => { showSuccessToast("Password updated successfully") form.reset() + onSuccess?.() }, onError: handleError.bind(showErrorToast), }) - const onSubmit = async (data: FormData) => { + const onSubmit = (data: ChangePasswordFormData) => { + if (mutation.isPending) return mutation.mutate(data) } return ( -
-

Change Password

+
+ {!embedded && ( +

+ Change password +

+ )}
( - Current Password + Current password @@ -97,15 +125,20 @@ const ChangePassword = () => { name="new_password" render={({ field, fieldState }) => ( - New Password + New password + + At least {PASSWORD_MIN_LENGTH} characters + )} @@ -116,12 +149,14 @@ const ChangePassword = () => { name="confirm_password" render={({ field, fieldState }) => ( - Confirm Password + Confirm new password @@ -133,14 +168,13 @@ const ChangePassword = () => { - Update Password + Update password
) } - -export default ChangePassword diff --git a/frontend/src/components/UserSettings/DangerZoneCard.tsx b/frontend/src/components/UserSettings/DangerZoneCard.tsx new file mode 100644 index 0000000000..10754263b2 --- /dev/null +++ b/frontend/src/components/UserSettings/DangerZoneCard.tsx @@ -0,0 +1,54 @@ +import { Trash2 } from "lucide-react" +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { DeleteAccountDialog } from "./DeleteAccountDialog" + +export function DangerZoneCard() { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + return ( + <> + + + Danger zone + + Irreversible and destructive actions + + + +
+
+ +

+ Permanently delete your account and all data. +

+
+ +
+
+
+ + + + ) +} diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx deleted file mode 100644 index 7b9e895ec5..0000000000 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import DeleteConfirmation from "./DeleteConfirmation" - -const DeleteAccount = () => { - return ( -
-

Delete Account

-

- Permanently delete your account and all associated data. -

- -
- ) -} - -export default DeleteAccount diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteAccountDialog.tsx similarity index 69% rename from frontend/src/components/UserSettings/DeleteConfirmation.tsx rename to frontend/src/components/UserSettings/DeleteAccountDialog.tsx index 06d76d9228..14aa64e790 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteAccountDialog.tsx @@ -11,14 +11,21 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" import { LoadingButton } from "@/components/ui/loading-button" import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" -const DeleteConfirmation = () => { +export type DeleteAccountDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function DeleteAccountDialog({ + open, + onOpenChange, +}: DeleteAccountDialogProps) { const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() const { handleSubmit } = useForm() @@ -28,6 +35,7 @@ const DeleteConfirmation = () => { mutationFn: () => UsersService.deleteUserMe(), onSuccess: () => { showSuccessToast("Your account has been successfully deleted") + onOpenChange(false) logout() }, onError: handleError.bind(showErrorToast), @@ -36,39 +44,38 @@ const DeleteConfirmation = () => { }, }) - const onSubmit = async () => { + const onSubmit = () => { mutation.mutate() } return ( - - - - + -
+ - Confirmation Required + Delete account All your account data will be{" "} - permanently deleted. If you are sure, please - click "Confirm" to proceed. This action cannot be - undone. + permanently deleted. If you are sure, click{" "} + Delete to proceed. This action cannot be undone. - + - Delete @@ -78,5 +85,3 @@ const DeleteConfirmation = () => {
) } - -export default DeleteConfirmation diff --git a/frontend/src/components/UserSettings/PersonalInfoCard.tsx b/frontend/src/components/UserSettings/PersonalInfoCard.tsx new file mode 100644 index 0000000000..0bec55c8de --- /dev/null +++ b/frontend/src/components/UserSettings/PersonalInfoCard.tsx @@ -0,0 +1,223 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { UsersService, type UserUpdateMe } from "@/client" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" +import { cn } from "@/lib/utils" +import { handleError } from "@/utils" + +const personalInfoSchema = z.object({ + full_name: z.string().max(30).optional().or(z.literal("")), + email: z.string().email({ message: "Invalid email address" }), +}) + +type PersonalInfoFormData = z.infer + +export function PersonalInfoCard() { + const queryClient = useQueryClient() + const { user } = useAuth() + const { showSuccessToast, showErrorToast } = useCustomToast() + const [editMode, setEditMode] = useState(false) + + const form = useForm({ + resolver: zodResolver(personalInfoSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + full_name: user?.full_name ?? undefined, + email: user?.email ?? "", + }, + }) + + const mutation = useMutation({ + mutationFn: (data: UserUpdateMe) => + UsersService.updateUserMe({ requestBody: data }), + onSuccess: () => { + showSuccessToast("Profile updated successfully") + setEditMode(false) + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["currentUser"] }) + }, + }) + + const onSubmit = (data: PersonalInfoFormData) => { + const payload: UserUpdateMe = {} + if (data.full_name !== (user?.full_name ?? undefined)) { + payload.full_name = data.full_name || null + } + if (data.email !== user?.email) { + payload.email = data.email + } + if (Object.keys(payload).length === 0) return + mutation.mutate(payload) + } + + const onCancel = () => { + form.reset({ + full_name: user?.full_name ?? undefined, + email: user?.email ?? "", + }) + setEditMode(false) + } + + // Sync form when user loads or changes, but not while editing + useEffect(() => { + if (user && !editMode) { + form.reset({ + full_name: user.full_name ?? undefined, + email: user.email ?? "", + }) + } + }, [user?.id, user?.full_name, user?.email, editMode, form.reset, user]) + + return ( + + +
+ Personal information + + Update your personal details and profile information. + +
+ {!editMode && ( + + )} +
+ + + +
+ + editMode ? ( + + Full name + + + + + + ) : ( + + + Full name + + + + ) + } + /> + + editMode ? ( + + Email + + + + + + ) : ( + + Email + + + ) + } + /> +
+ {editMode && ( +
+ + Save changes + + +
+ )} + + +
+
+ ) +} diff --git a/frontend/src/components/UserSettings/ProfileContent.tsx b/frontend/src/components/UserSettings/ProfileContent.tsx new file mode 100644 index 0000000000..5a3757e8cd --- /dev/null +++ b/frontend/src/components/UserSettings/ProfileContent.tsx @@ -0,0 +1,24 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { AccountSecurityCard } from "./AccountSecurityCard" +import { DangerZoneCard } from "./DangerZoneCard" +import { PersonalInfoCard } from "./PersonalInfoCard" + +export function ProfileContent() { + return ( + + + Personal + Account + + + + + + + + + + + + ) +} diff --git a/frontend/src/components/UserSettings/ProfileHeader.tsx b/frontend/src/components/UserSettings/ProfileHeader.tsx new file mode 100644 index 0000000000..9820952459 --- /dev/null +++ b/frontend/src/components/UserSettings/ProfileHeader.tsx @@ -0,0 +1,70 @@ +import { Calendar, Mail } from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent } from "@/components/ui/card" +import useAuth from "@/hooks/useAuth" + +function getInitials(fullName: string | null | undefined, email: string) { + if (fullName?.trim()) { + return fullName + .trim() + .split(/\s+/) + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + } + return email.slice(0, 2).toUpperCase() +} + +function formatJoinedDate(createdAt: string | null | undefined) { + if (!createdAt) return null + const date = new Date(createdAt) + return date.toLocaleDateString("en-US", { month: "long", year: "numeric" }) +} + +export function ProfileHeader() { + const { user } = useAuth() + + if (!user) return null + + const initials = getInitials(user.full_name, user.email) + const joinedDate = formatJoinedDate(user.created_at) + + return ( + + +
+
+ + + + {initials} + + +
+
+
+

+ {user.full_name?.trim() || user.email} +

+ {user.is_superuser && Admin} +
+
+
+ + {user.email} +
+ {joinedDate && ( +
+ + Joined {joinedDate} +
+ )} +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx deleted file mode 100644 index 4bfaf600ff..0000000000 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { UsersService, type UserUpdateMe } from "@/client" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { LoadingButton } from "@/components/ui/loading-button" -import useAuth from "@/hooks/useAuth" -import useCustomToast from "@/hooks/useCustomToast" -import { cn } from "@/lib/utils" -import { handleError } from "@/utils" - -const formSchema = z.object({ - full_name: z.string().max(30).optional(), - email: z.email({ message: "Invalid email address" }), -}) - -type FormData = z.infer - -const UserInformation = () => { - const queryClient = useQueryClient() - const { showSuccessToast, showErrorToast } = useCustomToast() - const [editMode, setEditMode] = useState(false) - const { user: currentUser } = useAuth() - - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - full_name: currentUser?.full_name ?? undefined, - email: currentUser?.email, - }, - }) - - const toggleEditMode = () => { - setEditMode(!editMode) - } - - const mutation = useMutation({ - mutationFn: (data: UserUpdateMe) => - UsersService.updateUserMe({ requestBody: data }), - onSuccess: () => { - showSuccessToast("User updated successfully") - toggleEditMode() - }, - onError: handleError.bind(showErrorToast), - onSettled: () => { - queryClient.invalidateQueries() - }, - }) - - const onSubmit = (data: FormData) => { - const updateData: UserUpdateMe = {} - - // only include fields that have changed - if (data.full_name !== currentUser?.full_name) { - updateData.full_name = data.full_name - } - if (data.email !== currentUser?.email) { - updateData.email = data.email - } - - mutation.mutate(updateData) - } - - const onCancel = () => { - form.reset() - toggleEditMode() - } - - return ( -
-

User Information

-
- - - editMode ? ( - - Full name - - - - - - ) : ( - - Full name -

- {field.value || "N/A"} -

-
- ) - } - /> - - - editMode ? ( - - Email - - - - - - ) : ( - - Email -

{field.value}

-
- ) - } - /> - -
- {editMode ? ( - <> - - Save - - - - ) : ( - - )} -
- - -
- ) -} - -export default UserInformation diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index 4cc1f82495..d470233d9b 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -1,17 +1,9 @@ import { createFileRoute } from "@tanstack/react-router" -import ChangePassword from "@/components/UserSettings/ChangePassword" -import DeleteAccount from "@/components/UserSettings/DeleteAccount" -import UserInformation from "@/components/UserSettings/UserInformation" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ProfileContent } from "@/components/UserSettings/ProfileContent" +import { ProfileHeader } from "@/components/UserSettings/ProfileHeader" import useAuth from "@/hooks/useAuth" -const tabsConfig = [ - { value: "my-profile", title: "My profile", component: UserInformation }, - { value: "password", title: "Password", component: ChangePassword }, - { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, -] - export const Route = createFileRoute("/_layout/settings")({ component: UserSettings, head: () => ({ @@ -24,38 +16,16 @@ export const Route = createFileRoute("/_layout/settings")({ }) function UserSettings() { - const { user: currentUser } = useAuth() - const finalTabs = currentUser?.is_superuser - ? tabsConfig.slice(0, 3) - : tabsConfig + const { user } = useAuth() - if (!currentUser) { + if (!user) { return null } return ( -
-
-

User Settings

-

- Manage your account settings and preferences -

-
- - - - {finalTabs.map((tab) => ( - - {tab.title} - - ))} - - {finalTabs.map((tab) => ( - - - - ))} - +
+ +
) } diff --git a/frontend/tests/user-settings.spec.ts b/frontend/tests/user-settings.spec.ts index 533ebb6207..c419f9e997 100644 --- a/frontend/tests/user-settings.spec.ts +++ b/frontend/tests/user-settings.spec.ts @@ -4,11 +4,11 @@ import { createUser } from "./utils/privateApi.ts" import { randomEmail, randomPassword } from "./utils/random" import { logInUser, logOutUser } from "./utils/user" -const tabs = ["My profile", "Password", "Danger zone"] +const tabs = ["Personal", "Account"] -test("My profile tab is active by default", async ({ page }) => { +test("Personal tab is active by default", async ({ page }) => { await page.goto("/settings") - await expect(page.getByRole("tab", { name: "My profile" })).toHaveAttribute( + await expect(page.getByRole("tab", { name: "Personal" })).toHaveAttribute( "aria-selected", "true", ) @@ -35,7 +35,7 @@ test.describe("Edit user profile", () => { test.beforeEach(async ({ page }) => { await logInUser(page, email, password) await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() + await page.getByRole("tab", { name: "Personal" }).click() }) test("Edit user name with a valid name", async ({ page }) => { @@ -43,12 +43,11 @@ test.describe("Edit user profile", () => { await page.getByRole("button", { name: "Edit" }).click() await page.getByLabel("Full name").fill(updatedName) - await page.getByRole("button", { name: "Save" }).click() + await page.getByRole("button", { name: "Save changes" }).click() - await expect(page.getByText("User updated successfully")).toBeVisible() - await expect( - page.locator("form").getByText(updatedName, { exact: true }), - ).toBeVisible() + await expect(page.getByText("Profile updated successfully")).toBeVisible() + // Wait for read-only mode to be restored (Edit button reappears) + await expect(page.getByRole("button", { name: "Edit" })).toBeVisible() }) test("Edit user email with an invalid email shows error", async ({ @@ -73,54 +72,36 @@ test.describe("Edit user email", () => { await createUser({ email, password }) await logInUser(page, email, password) await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() + await page.getByRole("tab", { name: "Personal" }).click() await page.getByRole("button", { name: "Edit" }).click() await page.getByLabel("Email").fill(updatedEmail) - await page.getByRole("button", { name: "Save" }).click() + await page.getByRole("button", { name: "Save changes" }).click() - await expect(page.getByText("User updated successfully")).toBeVisible() - await expect( - page.locator("form").getByText(updatedEmail, { exact: true }), - ).toBeVisible() + await expect(page.getByText("Profile updated successfully")).toBeVisible() + // Wait for read-only mode to be restored (Edit button reappears) + await expect(page.getByRole("button", { name: "Edit" })).toBeVisible() }) }) test.describe("Cancel edit actions", () => { test.use({ storageState: { cookies: [], origins: [] } }) - test("Cancel edit action restores original name", async ({ page }) => { - const email = randomEmail() - const password = randomPassword() - const user = await createUser({ email, password }) - - await logInUser(page, email, password) - await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() - await page.getByRole("button", { name: "Edit" }).click() - await page.getByLabel("Full name").fill("Test User") - await page.getByRole("button", { name: "Cancel" }).first().click() - - await expect( - page.locator("form").getByText(user.full_name as string, { exact: true }), - ).toBeVisible() - }) - - test("Cancel edit action restores original email", async ({ page }) => { + test("Cancel edit action restores original values", async ({ page }) => { const email = randomEmail() const password = randomPassword() await createUser({ email, password }) await logInUser(page, email, password) await page.goto("/settings") - await page.getByRole("tab", { name: "My profile" }).click() + await page.getByRole("tab", { name: "Personal" }).click() await page.getByRole("button", { name: "Edit" }).click() + await page.getByLabel("Full name").fill("Test User") await page.getByLabel("Email").fill(randomEmail()) await page.getByRole("button", { name: "Cancel" }).first().click() - await expect( - page.locator("form").getByText(email, { exact: true }), - ).toBeVisible() + // Wait for read-only mode to be restored (Edit button reappears) + await expect(page.getByRole("button", { name: "Edit" })).toBeVisible() }) }) @@ -136,11 +117,12 @@ test.describe("Change password", () => { await logInUser(page, email, password) await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() + await page.getByRole("tab", { name: "Account" }).click() + await page.getByRole("button", { name: "Change password" }).click() await page.getByTestId("current-password-input").fill(password) await page.getByTestId("new-password-input").fill(newPassword) await page.getByTestId("confirm-password-input").fill(newPassword) - await page.getByRole("button", { name: "Update Password" }).click() + await page.getByRole("button", { name: "Update password" }).click() await expect(page.getByText("Password updated successfully")).toBeVisible() @@ -163,7 +145,8 @@ test.describe("Change password validation", () => { test.beforeEach(async ({ page }) => { await logInUser(page, email, password) await page.goto("/settings") - await page.getByRole("tab", { name: "Password" }).click() + await page.getByRole("tab", { name: "Account" }).click() + await page.getByRole("button", { name: "Change password" }).click() }) test("Update password with weak passwords", async ({ page }) => { @@ -172,7 +155,7 @@ test.describe("Change password validation", () => { await page.getByTestId("current-password-input").fill(password) await page.getByTestId("new-password-input").fill(weakPassword) await page.getByTestId("confirm-password-input").fill(weakPassword) - await page.getByRole("button", { name: "Update Password" }).click() + await page.getByRole("button", { name: "Update password" }).click() await expect( page.getByText("Password must be at least 8 characters"), @@ -185,16 +168,16 @@ test.describe("Change password validation", () => { await page.getByTestId("current-password-input").fill(password) await page.getByTestId("new-password-input").fill(randomPassword()) await page.getByTestId("confirm-password-input").fill(randomPassword()) - await page.getByRole("button", { name: "Update Password" }).click() + await page.getByRole("button", { name: "Update password" }).click() - await expect(page.getByText("The passwords don't match")).toBeVisible() + await expect(page.getByText("Passwords do not match")).toBeVisible() }) test("Current password and new password are the same", async ({ page }) => { await page.getByTestId("current-password-input").fill(password) await page.getByTestId("new-password-input").fill(password) await page.getByTestId("confirm-password-input").fill(password) - await page.getByRole("button", { name: "Update Password" }).click() + await page.getByRole("button", { name: "Update password" }).click() await expect( page.getByText("New password cannot be the same as the current one"), @@ -254,3 +237,59 @@ test("Selected mode is preserved across sessions", async ({ page }) => { ) expect(isDarkMode).toBe(true) }) + +test.describe("Delete account", () => { + test.use({ storageState: { cookies: [], origins: [] } }) + + test("Delete account successfully", async ({ page }) => { + const email = randomEmail() + const password = randomPassword() + + await createUser({ email, password }) + await logInUser(page, email, password) + + await page.goto("/settings") + await page.getByRole("tab", { name: "Account" }).click() + await page.getByRole("button", { name: "Delete account" }).click() + + // Verify dialog is shown + await expect(page.getByRole("dialog")).toBeVisible() + await expect( + page.getByRole("heading", { name: "Delete account" }), + ).toBeVisible() + await expect( + page.getByText("All your account data will be permanently deleted"), + ).toBeVisible() + + // Confirm deletion + await page.getByRole("button", { name: "Delete", exact: true }).click() + + // Verify user is logged out and redirected to login (toast may not be visible due to redirect) + await expect(page).toHaveURL("/login") + }) + + test("Cancel delete account dialog", async ({ page }) => { + const email = randomEmail() + const password = randomPassword() + + await createUser({ email, password }) + await logInUser(page, email, password) + + await page.goto("/settings") + await page.getByRole("tab", { name: "Account" }).click() + await page.getByRole("button", { name: "Delete account" }).click() + + // Verify dialog is shown + await expect(page.getByRole("dialog")).toBeVisible() + + // Cancel deletion + await page.getByRole("button", { name: "Cancel" }).click() + + // Verify dialog is closed + await expect(page.getByRole("dialog")).not.toBeVisible() + + // Verify user is still logged in and can access settings + await expect(page).toHaveURL("/settings") + await expect(page.getByRole("tab", { name: "Personal" })).toBeVisible() + }) +})