feat(auth): add password change component and translations
Add PasswordChangeCard with Zod validation, Better Auth integration, and full EN/PL translations for settings page. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
3dc0d0713f
commit
137ea0287e
3 changed files with 309 additions and 0 deletions
|
|
@ -17,5 +17,29 @@
|
||||||
"ShowPasswordTooltip": "Show password",
|
"ShowPasswordTooltip": "Show password",
|
||||||
"Submit": "Submit",
|
"Submit": "Submit",
|
||||||
"RegisteredSuccessfully": "Registered successfully"
|
"RegisteredSuccessfully": "Registered successfully"
|
||||||
|
},
|
||||||
|
"DashboardPage": {
|
||||||
|
"SecurityCardTitle": "Account security",
|
||||||
|
"SecurityCardDescription": "Update your password and keep your account protected.",
|
||||||
|
"SecurityCardAction": "Open settings"
|
||||||
|
},
|
||||||
|
"SettingsPage": {
|
||||||
|
"Title": "Change password",
|
||||||
|
"Description": "Use your current password to set a new one for this account.",
|
||||||
|
"CurrentPasswordLabel": "Current password",
|
||||||
|
"CurrentPasswordPlaceholder": "Enter your current password",
|
||||||
|
"CurrentPasswordRequired": "Enter your current password",
|
||||||
|
"NewPasswordLabel": "New password",
|
||||||
|
"NewPasswordPlaceholder": "Enter your new password",
|
||||||
|
"PasswordTooShort": "Password must be at least 8 characters long",
|
||||||
|
"ConfirmPasswordLabel": "Confirm new password",
|
||||||
|
"ConfirmPasswordPlaceholder": "Repeat your new password",
|
||||||
|
"ConfirmPasswordRequired": "Repeat your new password",
|
||||||
|
"PasswordsDoNotMatch": "Passwords do not match",
|
||||||
|
"PasswordMustDiffer": "Your new password must be different from the current password",
|
||||||
|
"ShowPasswordTooltip": "Show password",
|
||||||
|
"HidePasswordTooltip": "Hide password",
|
||||||
|
"Submit": "Save new password",
|
||||||
|
"PasswordUpdatedSuccessfully": "Password updated successfully"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,29 @@
|
||||||
"ShowPasswordTooltip": "Pokaż hasło",
|
"ShowPasswordTooltip": "Pokaż hasło",
|
||||||
"Submit": "Wyślij",
|
"Submit": "Wyślij",
|
||||||
"RegisteredSuccessfully": "Zarejestrowano pomyślnie"
|
"RegisteredSuccessfully": "Zarejestrowano pomyślnie"
|
||||||
|
},
|
||||||
|
"DashboardPage": {
|
||||||
|
"SecurityCardTitle": "Bezpieczeństwo konta",
|
||||||
|
"SecurityCardDescription": "Zmień hasło i zadbaj o bezpieczeństwo swojego konta.",
|
||||||
|
"SecurityCardAction": "Otwórz ustawienia"
|
||||||
|
},
|
||||||
|
"SettingsPage": {
|
||||||
|
"Title": "Zmień hasło",
|
||||||
|
"Description": "Użyj obecnego hasła, aby ustawić nowe hasło dla tego konta.",
|
||||||
|
"CurrentPasswordLabel": "Obecne hasło",
|
||||||
|
"CurrentPasswordPlaceholder": "Wpisz obecne hasło",
|
||||||
|
"CurrentPasswordRequired": "Wpisz obecne hasło",
|
||||||
|
"NewPasswordLabel": "Nowe hasło",
|
||||||
|
"NewPasswordPlaceholder": "Wpisz nowe hasło",
|
||||||
|
"PasswordTooShort": "Hasło musi mieć co najmniej 8 znaków",
|
||||||
|
"ConfirmPasswordLabel": "Potwierdź nowe hasło",
|
||||||
|
"ConfirmPasswordPlaceholder": "Powtórz nowe hasło",
|
||||||
|
"ConfirmPasswordRequired": "Powtórz nowe hasło",
|
||||||
|
"PasswordsDoNotMatch": "Hasła nie są takie same",
|
||||||
|
"PasswordMustDiffer": "Nowe hasło musi różnić się od obecnego hasła",
|
||||||
|
"ShowPasswordTooltip": "Pokaż hasło",
|
||||||
|
"HidePasswordTooltip": "Ukryj hasło",
|
||||||
|
"Submit": "Zapisz nowe hasło",
|
||||||
|
"PasswordUpdatedSuccessfully": "Hasło zostało zmienione"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
261
src/components/settings/PasswordChangeCard.tsx
Normal file
261
src/components/settings/PasswordChangeCard.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { defaultPasswordValidator } from '@/constants';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@/components/ui/input-group';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
type PasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword';
|
||||||
|
|
||||||
|
type ChangePasswordMessages = {
|
||||||
|
currentPasswordRequired: string;
|
||||||
|
passwordTooShort: string;
|
||||||
|
confirmPasswordRequired: string;
|
||||||
|
passwordsDoNotMatch: string;
|
||||||
|
passwordMustDiffer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createChangePasswordSchema(messages: ChangePasswordMessages) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
currentPassword: z.string().min(1, {
|
||||||
|
error: messages.currentPasswordRequired,
|
||||||
|
}),
|
||||||
|
newPassword: defaultPasswordValidator(messages.passwordTooShort),
|
||||||
|
confirmPassword: z.string().min(1, {
|
||||||
|
error: messages.confirmPasswordRequired,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((value) => value.newPassword === value.confirmPassword, {
|
||||||
|
error: messages.passwordsDoNotMatch,
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
.refine((value) => value.currentPassword !== value.newPassword, {
|
||||||
|
error: messages.passwordMustDiffer,
|
||||||
|
path: ['newPassword'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordChangeCard() {
|
||||||
|
const t = useTranslations('SettingsPage');
|
||||||
|
const [visibleFields, setVisibleFields] = useState<Record<PasswordField, boolean>>({
|
||||||
|
currentPassword: false,
|
||||||
|
newPassword: false,
|
||||||
|
confirmPassword: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordSchema = createChangePasswordSchema({
|
||||||
|
currentPasswordRequired: t('CurrentPasswordRequired'),
|
||||||
|
passwordTooShort: t('PasswordTooShort'),
|
||||||
|
confirmPasswordRequired: t('ConfirmPasswordRequired'),
|
||||||
|
passwordsDoNotMatch: t('PasswordsDoNotMatch'),
|
||||||
|
passwordMustDiffer: t('PasswordMustDiffer'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ChangePasswordValues = z.infer<typeof changePasswordSchema>;
|
||||||
|
|
||||||
|
const form = useForm<ChangePasswordValues>({
|
||||||
|
resolver: zodResolver(changePasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
function toggleVisibility(field: PasswordField) {
|
||||||
|
setVisibleFields((current) => ({
|
||||||
|
...current,
|
||||||
|
[field]: !current[field],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: ChangePasswordValues) {
|
||||||
|
const result = await authClient.changePassword({
|
||||||
|
currentPassword: values.currentPassword,
|
||||||
|
newPassword: values.newPassword,
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
toast(t('PasswordUpdatedSuccessfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('Title')}</CardTitle>
|
||||||
|
<CardDescription>{t('Description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id="password-change-form" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
name="currentPassword"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="current-password">
|
||||||
|
{t('CurrentPasswordLabel')}
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
{...field}
|
||||||
|
id="current-password"
|
||||||
|
name="currentPassword"
|
||||||
|
type={visibleFields.currentPassword ? 'text' : 'password'}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder={t('CurrentPasswordPlaceholder')}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon
|
||||||
|
align="inline-end"
|
||||||
|
onClick={() => toggleVisibility('currentPassword')}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{visibleFields.currentPassword ? (
|
||||||
|
<HugeiconsIcon icon={EyeOff} />
|
||||||
|
) : (
|
||||||
|
<HugeiconsIcon icon={EyeIcon} />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{visibleFields.currentPassword
|
||||||
|
? t('HidePasswordTooltip')
|
||||||
|
: t('ShowPasswordTooltip')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="newPassword"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="new-password">{t('NewPasswordLabel')}</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
{...field}
|
||||||
|
id="new-password"
|
||||||
|
name="newPassword"
|
||||||
|
type={visibleFields.newPassword ? 'text' : 'password'}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder={t('NewPasswordPlaceholder')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon
|
||||||
|
align="inline-end"
|
||||||
|
onClick={() => toggleVisibility('newPassword')}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{visibleFields.newPassword ? (
|
||||||
|
<HugeiconsIcon icon={EyeOff} />
|
||||||
|
) : (
|
||||||
|
<HugeiconsIcon icon={EyeIcon} />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{visibleFields.newPassword
|
||||||
|
? t('HidePasswordTooltip')
|
||||||
|
: t('ShowPasswordTooltip')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirmPassword"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="confirm-password">
|
||||||
|
{t('ConfirmPasswordLabel')}
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
{...field}
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={visibleFields.confirmPassword ? 'text' : 'password'}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder={t('ConfirmPasswordPlaceholder')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon
|
||||||
|
align="inline-end"
|
||||||
|
onClick={() => toggleVisibility('confirmPassword')}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{visibleFields.confirmPassword ? (
|
||||||
|
<HugeiconsIcon icon={EyeOff} />
|
||||||
|
) : (
|
||||||
|
<HugeiconsIcon icon={EyeIcon} />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{visibleFields.confirmPassword
|
||||||
|
? t('HidePasswordTooltip')
|
||||||
|
: t('ShowPasswordTooltip')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" form="password-change-form" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? <Spinner /> : t('Submit')}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue