Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions frontend/src/components/UserSettings/AccountSecurityCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Manage your account status and password.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">Account status</Label>
<p className="text-muted-foreground text-sm">
Your account is currently active.
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper text says "Your account is currently active." unconditionally, but the badge can show "Inactive" when user?.is_active is false. Consider making this copy conditional so the status description matches the actual account state.

Suggested change
Your account is currently active.
{user?.is_active
? "Your account is currently active."
: "Your account is currently inactive."}

Copilot uses AI. Check for mistakes.
</p>
</div>
<Badge
variant="outline"
className={
user?.is_active
? "shrink-0 border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950 dark:text-green-400"
: "shrink-0 border-muted text-muted-foreground"
}
>
{user?.is_active ? "Active" : "Inactive"}
</Badge>
</div>

<Separator />

<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">Password</Label>
<p className="text-muted-foreground text-sm">
Update your password.
</p>
</div>
<Dialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="shrink-0">
<Key className="mr-2 h-4 w-4" aria-hidden />
Change password
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
Enter your current password and choose a new one.
</DialogDescription>
</DialogHeader>
<ChangePassword
onSuccess={() => setChangePasswordOpen(false)}
embedded
/>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
)
}
88 changes: 61 additions & 27 deletions frontend/src/components/UserSettings/ChangePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type UpdatePassword, UsersService } from "@/client"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Expand All @@ -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<typeof changePasswordSchema>

type FormData = z.infer<typeof formSchema>
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<FormData>({
resolver: zodResolver(formSchema),
mode: "onSubmit",

const form = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
current_password: "",
Expand All @@ -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 (
<div className="max-w-md">
<h3 className="text-lg font-semibold py-4">Change Password</h3>
<div className={embedded ? "pt-1" : "max-w-md"}>
{!embedded && (
<h2 className="text-lg font-semibold tracking-tight pb-4">
Change password
</h2>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
noValidate
className="flex flex-col gap-6"
>
<FormField
control={form.control}
name="current_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormLabel>Current password</FormLabel>
<FormControl>
<PasswordInput
data-testid="current-password-input"
placeholder="••••••••"
autoComplete="current-password"
aria-invalid={fieldState.invalid}
disabled={mutation.isPending}
{...field}
/>
</FormControl>
Expand All @@ -97,15 +125,20 @@ const ChangePassword = () => {
name="new_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormLabel>New password</FormLabel>
<FormControl>
<PasswordInput
data-testid="new-password-input"
placeholder="••••••••"
autoComplete="new-password"
aria-invalid={fieldState.invalid}
disabled={mutation.isPending}
{...field}
/>
</FormControl>
<FormDescription>
At least {PASSWORD_MIN_LENGTH} characters
</FormDescription>
<FormMessage />
</FormItem>
)}
Expand All @@ -116,12 +149,14 @@ const ChangePassword = () => {
name="confirm_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormLabel>Confirm new password</FormLabel>
<FormControl>
<PasswordInput
data-testid="confirm-password-input"
placeholder="••••••••"
autoComplete="new-password"
aria-invalid={fieldState.invalid}
disabled={mutation.isPending}
{...field}
/>
</FormControl>
Expand All @@ -133,14 +168,13 @@ const ChangePassword = () => {
<LoadingButton
type="submit"
loading={mutation.isPending}
className="self-start"
disabled={!form.formState.isDirty || mutation.isPending}
className={embedded ? "w-full sm:w-auto" : "w-full sm:w-auto"}
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional is redundant: both branches produce the same class string. It can be simplified to a single literal to reduce noise and make future changes less error-prone.

Suggested change
className={embedded ? "w-full sm:w-auto" : "w-full sm:w-auto"}
className="w-full sm:w-auto"

Copilot uses AI. Check for mistakes.
>
Update Password
Update password
</LoadingButton>
</form>
</Form>
</div>
)
}

export default ChangePassword
54 changes: 54 additions & 0 deletions frontend/src/components/UserSettings/DangerZoneCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Danger zone</CardTitle>
<CardDescription>
Irreversible and destructive actions
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">Delete account</Label>
<p className="text-muted-foreground text-sm">
Permanently delete your account and all data.
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
className="shrink-0"
>
<Trash2 className="mr-2 h-4 w-4" aria-hidden />
Delete account
</Button>
</div>
</CardContent>
</Card>

<DeleteAccountDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</>
)
}
15 changes: 0 additions & 15 deletions frontend/src/components/UserSettings/DeleteAccount.tsx

This file was deleted.

Loading
Loading