- Use real logo/isotype SVGs across app and emails - Token-based color system aligned with landing (brand scale + semantic tokens) - ThemeProvider (light/dark/system) with header toggle and settings card - Embed logo in transactional emails; centralize email colors
183 lines
6.2 KiB
TypeScript
183 lines
6.2 KiB
TypeScript
import { useState } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { z } from 'zod'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { Fingerprint, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
|
import { authClient } from '../lib/auth-client'
|
|
import { Button, Input, Label } from '../components/ui'
|
|
|
|
const changePasswordSchema = z
|
|
.object({
|
|
currentPassword: z.string().min(1, 'Ingresá tu contraseña actual'),
|
|
newPassword: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
|
})
|
|
.refine((v) => v.currentPassword !== v.newPassword, {
|
|
message: 'La nueva contraseña debe ser distinta',
|
|
path: ['newPassword'],
|
|
})
|
|
|
|
type ChangePasswordValues = z.infer<typeof changePasswordSchema>
|
|
|
|
function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
|
|
const { data, isPending } = authClient.useListPasskeys()
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
|
|
const handleDelete = async (id: string) => {
|
|
setDeletingId(id)
|
|
const { error } = await authClient.$fetch('/passkey/delete-passkey', {
|
|
method: 'POST',
|
|
body: { id },
|
|
})
|
|
setDeletingId(null)
|
|
if (!error) onRefresh()
|
|
}
|
|
|
|
if (isPending) {
|
|
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
|
}
|
|
|
|
const passkeys = data ?? []
|
|
if (passkeys.length === 0) {
|
|
return <p className="text-sm text-foreground/60">Todavía no registraste ninguna passkey.</p>
|
|
}
|
|
|
|
return (
|
|
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
|
{passkeys.map((passkey) => (
|
|
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
|
|
<span className="rounded-lg bg-accent-soft p-2">
|
|
<Fingerprint className="size-4 text-accent" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-primary">
|
|
{passkey.name ?? 'Passkey'}
|
|
</p>
|
|
<p className="text-xs text-foreground/50">
|
|
{passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '}
|
|
{new Date(passkey.createdAt).toLocaleDateString('es-AR')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
aria-label="Eliminar passkey"
|
|
disabled={deletingId === passkey.id}
|
|
onClick={() => handleDelete(passkey.id)}
|
|
>
|
|
<Trash2 className="size-4 text-danger" />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
export function SecurityPage() {
|
|
const [passkeyKeys, setPasskeyKeys] = useState(0)
|
|
const [feedback, setFeedback] = useState<string | null>(null)
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
setError,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<ChangePasswordValues>({ resolver: zodResolver(changePasswordSchema) })
|
|
|
|
const refreshPasskeys = () => {
|
|
setPasskeyKeys((k) => k + 1)
|
|
}
|
|
|
|
const handleAddPasskey = async () => {
|
|
setFeedback(null)
|
|
const { error } = await authClient.passkey.addPasskey()
|
|
if (error) setFeedback(`No se pudo agregar: ${error.message}`)
|
|
else {
|
|
setFeedback('Passkey registrada correctamente.')
|
|
refreshPasskeys()
|
|
}
|
|
}
|
|
|
|
const onChangePassword = handleSubmit(async ({ currentPassword, newPassword }) => {
|
|
setFeedback(null)
|
|
const { error } = await authClient.changePassword({
|
|
currentPassword,
|
|
newPassword,
|
|
revokeOtherSessions: true,
|
|
})
|
|
if (error) {
|
|
setError('currentPassword', { message: error.message ?? 'No se pudo cambiar la contraseña' })
|
|
return
|
|
}
|
|
reset({ currentPassword: '', newPassword: '' })
|
|
setFeedback('Contraseña actualizada.')
|
|
})
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-primary">Seguridad</h1>
|
|
<p className="mt-1 text-sm text-foreground/60">
|
|
Gestioná tu contraseña y tus llaves de acceso (passkeys).
|
|
</p>
|
|
</div>
|
|
|
|
{feedback ? (
|
|
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
|
|
) : null}
|
|
|
|
<div className="rounded-xl border border-border bg-surface p-5">
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<KeyRound className="size-4 text-accent" />
|
|
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
|
|
</div>
|
|
<form onSubmit={onChangePassword} className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="currentPassword">Contraseña actual</Label>
|
|
<Input
|
|
id="currentPassword"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
invalid={!!errors.currentPassword}
|
|
{...register('currentPassword')}
|
|
/>
|
|
{errors.currentPassword ? (
|
|
<p className="mt-1 text-sm text-danger">{errors.currentPassword.message}</p>
|
|
) : null}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
|
<Input
|
|
id="newPassword"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
invalid={!!errors.newPassword}
|
|
{...register('newPassword')}
|
|
/>
|
|
{errors.newPassword ? (
|
|
<p className="mt-1 text-sm text-danger">{errors.newPassword.message}</p>
|
|
) : null}
|
|
</div>
|
|
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
|
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
Actualizar contraseña
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-border bg-surface p-5">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Fingerprint className="size-4 text-accent" />
|
|
<h2 className="text-base font-semibold text-primary">Passkeys</h2>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleAddPasskey}>
|
|
<Plus className="size-4" />
|
|
Registrar
|
|
</Button>
|
|
</div>
|
|
<PasskeyList key={passkeyKeys} onRefresh={refreshPasskeys} />
|
|
</div>
|
|
</section>
|
|
)
|
|
} |