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",
|
||||
"Submit": "Submit",
|
||||
"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",
|
||||
"Submit": "Wyślij",
|
||||
"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