feat: basic initial UI layout for mobile and desktop
This commit is contained in:
183
apps/web/src/routes/security.tsx
Normal file
183
apps/web/src/routes/security.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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-white">
|
||||
{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-white 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-white 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user