Files
gruperly/apps/web/src/routes/security.tsx
Jose Selesan 6100c998be feat: add brand assets, design tokens and light/dark theme
- 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
2026-09-16 17:35:24 -03:00

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