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:
nxtkofi 2026-04-21 20:53:23 +02:00
parent 3dc0d0713f
commit 137ea0287e
3 changed files with 309 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -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"
}
}

View 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>
);
}