-
-
Notifications
You must be signed in to change notification settings - Fork 8k
Redesign User Settings UI with profile header and tabbed layout #2162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
68e0d64
✨ Add new UserSettings profile and card components
AchuAshwath 0a3600f
♻️ Refactor ChangePassword to work as embedded dialog
AchuAshwath 5f357f2
🔥 Remove old UserSettings components
AchuAshwath 35b00e4
✨ Update settings route to use new UserSettings components
AchuAshwath b0d12b3
✅ Update E2E tests for new User Settings UI
AchuAshwath File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
93 changes: 93 additions & 0 deletions
93
frontend/src/components/UserSettings/AccountSecurityCard.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| </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> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<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: "", | ||||||
|
|
@@ -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> | ||||||
|
|
@@ -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> | ||||||
| )} | ||||||
|
|
@@ -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> | ||||||
|
|
@@ -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"} | ||||||
|
||||||
| className={embedded ? "w-full sm:w-auto" : "w-full sm:w-auto"} | |
| className="w-full sm:w-auto" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| /> | ||
| </> | ||
| ) | ||
| } |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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_activeis false. Consider making this copy conditional so the status description matches the actual account state.