-
Notifications
You must be signed in to change notification settings - Fork 0
132 security dialog #155
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
base: main
Are you sure you want to change the base?
132 security dialog #155
Changes from all commits
a64e686
e6b7dd1
8049d83
f443b11
96edc26
b709f6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -1,3 +1,244 @@ | ||||
| import { zodResolver } from "@hookform/resolvers/zod"; | ||||
| import { useState } from "react"; | ||||
| import { useForm } from "react-hook-form"; | ||||
| import { z } from "zod"; | ||||
|
|
||||
| import { authClient } from "@/lib/auth/client"; | ||||
| import { forceLogout } from "@/lib/auth/logout"; | ||||
| import { getBetterAuthErrorMessage } from "@/lib/auth/extensions/get-better-auth-error"; | ||||
|
|
||||
| import { FormInputField } from "@/components/form/FormInput"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | ||||
| import { Separator } from "@/components/ui/separator"; | ||||
| import { Spinner } from "@/components/ui/spinner"; | ||||
| import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; | ||||
| import { AlertCircle, CheckCircle2, LogOut, Shield, Lock } from "lucide-react"; | ||||
|
|
||||
| const ChangePasswordSchema = z | ||||
| .object({ | ||||
| currentPassword: z.string().nonempty("Please fill out this field."), | ||||
| newPassword: z | ||||
| .string() | ||||
| .nonempty("Please fill out this field.") | ||||
| .min(8, "Password must be at least 8 characters long."), | ||||
| confirmPassword: z.string().nonempty("Please fill out this field."), | ||||
| }) | ||||
| .superRefine((val, ctx) => { | ||||
| if (val.newPassword !== val.confirmPassword) { | ||||
| ctx.addIssue({ | ||||
| code: "custom", | ||||
| path: ["confirmPassword"], | ||||
| message: "Passwords don't match.", | ||||
| }); | ||||
| } | ||||
| }); | ||||
|
|
||||
| type ChangePasswordSchemaType = z.infer<typeof ChangePasswordSchema>; | ||||
|
|
||||
| export function SecuritySettingsContent() { | ||||
| return <>Security coming soon...</>; | ||||
| } | ||||
| const [passwordSuccess, setPasswordSuccess] = useState(false) | ||||
| const [sessionMessage, setSessionMessage] = useState<{ | ||||
| type: "success" | "error"; | ||||
| text: string; | ||||
| } | null>(null); | ||||
| const [sessionLoading, setSessionLoading] = useState(false); | ||||
|
|
||||
| const { | ||||
| handleSubmit, | ||||
| setError, | ||||
| control, | ||||
| reset, | ||||
| formState: { errors, isSubmitting, isDirty }, | ||||
| } = useForm<ChangePasswordSchemaType>({ | ||||
| resolver: zodResolver(ChangePasswordSchema), | ||||
| mode: "onSubmit", | ||||
| reValidateMode: "onChange", | ||||
| defaultValues: { | ||||
| currentPassword: "", | ||||
| newPassword: "", | ||||
| confirmPassword: "", | ||||
| }, | ||||
| }); | ||||
|
|
||||
| const onSubmit = async (data: ChangePasswordSchemaType) => { | ||||
| const { error } = await authClient.changePassword({ | ||||
| currentPassword: data.currentPassword, | ||||
| newPassword: data.newPassword, | ||||
| revokeOtherSessions: false, | ||||
| }); | ||||
|
|
||||
| if (error) { | ||||
| setError("root", { | ||||
| type: "custom", | ||||
| message: getBetterAuthErrorMessage(error?.code), | ||||
| }); | ||||
| return; | ||||
| } | ||||
|
|
||||
| reset(); | ||||
| setPasswordSuccess(true); | ||||
| }; | ||||
|
|
||||
| const handleRevokeAllSessions = async () => { | ||||
| try { | ||||
| setSessionLoading(true); | ||||
| await authClient.revokeOtherSessions(); | ||||
| setSessionMessage({ type: "success", text: "All other sessions have been revoked." }); | ||||
| } catch { | ||||
| setSessionMessage({ type: "error", text: "Failed to revoke sessions. Please try again." }); | ||||
| } finally { | ||||
| setSessionLoading(false); | ||||
| } | ||||
| }; | ||||
|
|
||||
| const isPasswordSuccess = (errors.root as any)?.type === "success"; | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unused variable -
Suggested change
|
||||
|
|
||||
| return ( | ||||
| <div className="space-y-6 px-1"> | ||||
| <div> | ||||
| <p className="text-sm text-muted-foreground"> | ||||
| Manage your account security and sessions | ||||
| </p> | ||||
| </div> | ||||
| <Separator /> | ||||
|
Comment on lines
+99
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate "Security" heading
The other content components (e.g. |
||||
|
|
||||
| <Card> | ||||
| <CardHeader> | ||||
| <CardTitle className="flex items-center gap-2"> | ||||
| <Lock className="h-5 w-5" /> | ||||
| Change Password | ||||
| </CardTitle> | ||||
| <CardDescription> | ||||
| Update your password to keep your account secure | ||||
| </CardDescription> | ||||
| </CardHeader> | ||||
| <CardContent> | ||||
| <form | ||||
| onSubmit={handleSubmit(onSubmit)} | ||||
| noValidate | ||||
| className="space-y-4" | ||||
| > | ||||
| {errors.root?.message && ( | ||||
| <Alert variant="destructive" role="alert" aria-live="assertive"> | ||||
| <AlertCircle className="h-4 w-4" /> | ||||
| <AlertTitle>Couldn't update password</AlertTitle> | ||||
| <AlertDescription>{errors.root.message}</AlertDescription> | ||||
| </Alert> | ||||
| )} | ||||
|
|
||||
| {passwordSuccess && !isDirty && ( | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Success message can reappear if user types then clears form back to empty. Once user starts editing ( Add useEffect to clear success state: useEffect(() => {
if (isDirty) setPasswordSuccess(false);
}, [isDirty]); |
||||
| <Alert variant="success" role="status" aria-live="polite"> | ||||
| <CheckCircle2 className="h-4 w-4" /> | ||||
| <AlertTitle>Success</AlertTitle> | ||||
| <AlertDescription>Password changed successfully.</AlertDescription> | ||||
| </Alert> | ||||
| )} | ||||
|
|
||||
| <FormInputField | ||||
| control={control} | ||||
| type="password" | ||||
| name="currentPassword" | ||||
| placeholder="•••••••••••••" | ||||
| label="Current Password" | ||||
| className="gap-1" | ||||
| /> | ||||
| <FormInputField | ||||
| control={control} | ||||
| type="password" | ||||
| name="newPassword" | ||||
| autoComplete="new-password" | ||||
| placeholder="•••••••••••••" | ||||
| label="New Password (at least 8 characters)" | ||||
| className="gap-1" | ||||
| /> | ||||
| <FormInputField | ||||
| control={control} | ||||
| type="password" | ||||
| name="confirmPassword" | ||||
| autoComplete="new-password" | ||||
| placeholder="•••••••••••••" | ||||
| label="Confirm New Password" | ||||
| className="gap-1" | ||||
| /> | ||||
|
|
||||
| <Button type="submit" disabled={isSubmitting}> | ||||
| {isSubmitting ? ( | ||||
| <> | ||||
| <Spinner /> Updating... | ||||
| </> | ||||
| ) : ( | ||||
| "Update Password" | ||||
| )} | ||||
| </Button> | ||||
| </form> | ||||
| </CardContent> | ||||
| </Card> | ||||
|
|
||||
| <Card> | ||||
| <CardHeader> | ||||
| <CardTitle className="flex items-center gap-2"> | ||||
| <Shield className="h-5 w-5" /> | ||||
| Session Management | ||||
| </CardTitle> | ||||
| <CardDescription> | ||||
| Manage your active sessions across all devices | ||||
| </CardDescription> | ||||
| </CardHeader> | ||||
| <CardContent className="space-y-4"> | ||||
| {sessionMessage && ( | ||||
| <Alert | ||||
| variant={sessionMessage.type === "error" ? "destructive" : "default"} | ||||
| role={sessionMessage.type === "error" ? "alert" : "status"} | ||||
| aria-live={sessionMessage.type === "error" ? "assertive" : "polite"} | ||||
| > | ||||
| {sessionMessage.type === "error" ? ( | ||||
| <AlertCircle className="h-4 w-4" /> | ||||
| ) : ( | ||||
| <CheckCircle2 className="h-4 w-4" /> | ||||
| )} | ||||
| <AlertDescription>{sessionMessage.text}</AlertDescription> | ||||
| </Alert> | ||||
| )} | ||||
|
|
||||
| <div className="flex items-center justify-between"> | ||||
| <div className="space-y-0.5"> | ||||
| <p className="text-sm font-medium">Logout from all devices</p> | ||||
| <p className="text-sm text-muted-foreground"> | ||||
| End all sessions except the current one | ||||
| </p> | ||||
| </div> | ||||
| <Button | ||||
| variant="outline" | ||||
| onClick={handleRevokeAllSessions} | ||||
| disabled={sessionLoading} | ||||
| > | ||||
| {sessionLoading ? <Spinner /> : "Revoke All Sessions"} | ||||
| </Button> | ||||
| </div> | ||||
| </CardContent> | ||||
| </Card> | ||||
|
|
||||
| {/* Logout */} | ||||
| <Card> | ||||
| <CardHeader> | ||||
| <CardTitle className="flex items-center gap-2"> | ||||
| <LogOut className="h-5 w-5" /> | ||||
| Logout | ||||
| </CardTitle> | ||||
| <CardDescription>Sign out of your account on this device</CardDescription> | ||||
| </CardHeader> | ||||
| <CardContent> | ||||
| <Button | ||||
| variant="destructive" | ||||
| onClick={forceLogout} | ||||
| className="w-full sm:w-auto" | ||||
| > | ||||
| <LogOut className="mr-2 h-4 w-4" /> | ||||
| Logout | ||||
| </Button> | ||||
| </CardContent> | ||||
| </Card> | ||||
| </div> | ||||
| ); | ||||
| } | ||||
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.
revokeOtherSessions: falseis a security riskWhen a user changes their password, failing to revoke other active sessions means any compromised session (e.g., an attacker who already has a valid token on another device) remains valid after the password change. Revoking other sessions on password change is a widely accepted security best practice.
Consider setting this to
trueby default — the user can still use the separate "Revoke All Sessions" button if needed, but a password change should at minimum invalidate all sessions besides the current one.