Skip to content

Commit 724825a

Browse files
committed
feat(cuentas): dos pasos con app autenticadora
Un solo diálogo guía todo: elegir método con la app recomendada, escanear el QR o abrir la app en el teléfono, guardar los códigos de respaldo y, después, gestionarlos o desactivar. El login pide el código de la app y ofrece el de respaldo a quien no tiene el teléfono a mano.
1 parent d287c8b commit 724825a

17 files changed

Lines changed: 1655 additions & 261 deletions

‎package-lock.json‎

Lines changed: 308 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@testing-library/react": "^16.3.3",
2121
"@testing-library/user-event": "^14.6.7",
2222
"@types/node": "^26.4.1",
23+
"@types/qrcode": "^1.5.6",
2324
"@types/react": "^19.2.18",
2425
"@types/react-dom": "^19.2.7",
2526
"@vitejs/plugin-react": "^6.1.1",
@@ -42,6 +43,7 @@
4243
"class-variance-authority": "^0.7.1",
4344
"cn": "^0.2.5",
4445
"lucide-react": "^1.41.0",
46+
"qrcode": "^1.5.4",
4547
"radix-ui": "^1.6.7",
4648
"react": "^19.2.8",
4749
"react-dom": "^19.2.8",

‎src/features/account/AccountPage.tsx‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,19 @@ import {
2525
} from '@/features/account/AccountFieldDialog'
2626
import { OpenSessions } from '@/features/account/OpenSessions'
2727
import { TimeZoneSettings } from '@/features/account/TimeZoneSettings'
28-
import {
29-
TwoFactorDisableDialog,
30-
TwoFactorEnableDialog,
31-
} from '@/features/account/TwoFactorDialog'
28+
import { describirDosPasos, quedanPocosRespaldos } from '@/features/account/dos-pasos'
29+
import { TwoFactorDialog } from '@/features/account/TwoFactorDialog'
3230
import { useSession } from '@/features/auth/session'
3331
import { formatearTelefono } from '@/lib/telefonos'
3432

3533
type Ajustable = 'nombre' | 'telefono' | 'correo' | 'clave' | 'dos-pasos'
3634

35+
const PISTA_DE_DOS_PASOS = {
36+
desactivada: 'Un código además de la contraseña en cada inicio de sesión.',
37+
correo: 'Un código al correo en cada inicio de sesión.',
38+
app: 'El código de su app en cada inicio de sesión.',
39+
} as const
40+
3741
/** Datos de la cuenta y forma de acceso; cada dato se cambia por separado. */
3842
export function AccountPage() {
3943
const { user } = useSession()
@@ -47,6 +51,7 @@ export function AccountPage() {
4751

4852
const alCerrar = (clave: Ajustable) => (abierto: boolean) =>
4953
setEditando(abierto ? clave : null)
54+
const dosPasos = describirDosPasos(user)
5055

5156
return (
5257
<div className="space-y-6">
@@ -127,20 +132,25 @@ export function AccountPage() {
127132
<SettingsRow
128133
icono={ShieldCheckIcon}
129134
etiqueta="Verificación en dos pasos"
130-
pista="Un código al correo en cada inicio de sesión."
131-
accion={user.two_factor_enabled ? 'Desactivar' : 'Activar'}
135+
pista={PISTA_DE_DOS_PASOS[dosPasos]}
136+
accion={dosPasos === 'desactivada' ? 'Activar' : 'Gestionar'}
132137
onEditar={() => setEditando('dos-pasos')}
133138
>
134-
{user.two_factor_enabled ? (
135-
<span className="text-success inline-flex items-center gap-1.5">
136-
<ShieldCheckIcon className="size-4" />
137-
Activada
138-
</span>
139-
) : (
139+
{dosPasos === 'desactivada' ? (
140140
<span className="text-warning inline-flex items-center gap-1.5">
141141
<ShieldAlertIcon className="size-4" />
142142
Desactivada
143143
</span>
144+
) : quedanPocosRespaldos(user.recovery_codes_left) ? (
145+
<span className="text-warning inline-flex items-center gap-1.5">
146+
<ShieldAlertIcon className="size-4" />
147+
Pocos códigos de respaldo
148+
</span>
149+
) : (
150+
<span className="text-success inline-flex items-center gap-1.5">
151+
<ShieldCheckIcon className="size-4" />
152+
{dosPasos === 'app' ? 'App autenticadora' : 'Código por correo'}
153+
</span>
144154
)}
145155
</SettingsRow>
146156
{user.social_accounts.length > 0 && (
@@ -178,13 +188,9 @@ export function AccountPage() {
178188
open={editando === 'clave'}
179189
onOpenChange={alCerrar('clave')}
180190
/>
181-
<TwoFactorEnableDialog
182-
email={user.email}
183-
open={editando === 'dos-pasos' && !user.two_factor_enabled}
184-
onOpenChange={alCerrar('dos-pasos')}
185-
/>
186-
<TwoFactorDisableDialog
187-
open={editando === 'dos-pasos' && user.two_factor_enabled}
191+
<TwoFactorDialog
192+
user={user}
193+
open={editando === 'dos-pasos'}
188194
onOpenChange={alCerrar('dos-pasos')}
189195
/>
190196
</div>
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import { cn } from 'cn'
3+
import {
4+
CheckIcon,
5+
ChevronDownIcon,
6+
CopyIcon,
7+
ExternalLinkIcon,
8+
Loader2Icon,
9+
SmartphoneIcon,
10+
} from 'lucide-react'
11+
import { useState } from 'react'
12+
import { toast } from 'sonner'
13+
14+
import { CodeInput } from '@/components/code-input'
15+
import { FieldError } from '@/components/field-error'
16+
import { PasswordInput } from '@/components/password-input'
17+
import { Button } from '@/components/ui/button'
18+
import {
19+
DialogDescription,
20+
DialogFooter,
21+
DialogHeader,
22+
DialogTitle,
23+
} from '@/components/ui/dialog'
24+
import { Label } from '@/components/ui/label'
25+
import { CodigoQR } from '@/features/account/CodigoQR'
26+
import { agruparClave } from '@/features/account/dos-pasos'
27+
import { RecoveryCodes } from '@/features/account/RecoveryCodes'
28+
import * as authApi from '@/features/auth/api'
29+
import { CLAVE_USUARIO } from '@/features/auth/session'
30+
import { toApiError } from '@/lib/api-error'
31+
32+
type Paso = 'clave' | 'escanear' | 'respaldo'
33+
34+
const NUMERO: Record<Paso, number> = { clave: 1, escanear: 2, respaldo: 3 }
35+
36+
interface Props {
37+
correo: string
38+
onVolver: () => void
39+
/** El diálogo no se cierra con los códigos sin guardar. */
40+
onBloquear: (bloqueado: boolean) => void
41+
onListo: () => void
42+
}
43+
44+
/** Contraseña, QR y códigos de respaldo. Hasta el primer código correcto
45+
* la cuenta sigue como estaba. */
46+
export function AuthenticatorSetup({ correo, onVolver, onBloquear, onListo }: Props) {
47+
const cliente = useQueryClient()
48+
const [paso, setPaso] = useState<Paso>('clave')
49+
const [clave, setClave] = useState('')
50+
const [codigo, setCodigo] = useState('')
51+
const [error, setError] = useState<string | undefined>()
52+
const [alta, setAlta] = useState<{ secret: string; uri: string } | null>(null)
53+
const [respaldos, setRespaldos] = useState<string[]>([])
54+
const [guardados, setGuardados] = useState(false)
55+
56+
const empezar = useMutation({
57+
mutationFn: () => authApi.startAuthenticatorSetup(clave),
58+
onSuccess: (datos) => {
59+
setAlta(datos)
60+
setError(undefined)
61+
setPaso('escanear')
62+
},
63+
onError: (fallo) => {
64+
const api = toApiError(fallo)
65+
setError(api.fieldErrors.current_password?.[0] ?? api.message)
66+
},
67+
})
68+
69+
const confirmar = useMutation({
70+
mutationFn: (valor: string) => authApi.confirmAuthenticator(valor),
71+
onSuccess: async ({ recovery_codes }) => {
72+
setRespaldos(recovery_codes)
73+
setPaso('respaldo')
74+
onBloquear(true)
75+
await cliente.invalidateQueries({ queryKey: CLAVE_USUARIO })
76+
},
77+
onError: (fallo) => {
78+
const api = toApiError(fallo)
79+
setError(api.fieldErrors.code?.[0] ?? api.message)
80+
setCodigo('')
81+
},
82+
})
83+
84+
// Seis cifras: se comprueba solo
85+
const alTeclear = (valor: string) => {
86+
setCodigo(valor)
87+
setError(undefined)
88+
if (valor.length === 6 && !confirmar.isPending) confirmar.mutate(valor)
89+
}
90+
91+
const terminar = () => {
92+
onBloquear(false)
93+
toast.success('App autenticadora activada.')
94+
onListo()
95+
}
96+
97+
return (
98+
<>
99+
<DialogHeader className="space-y-3">
100+
<span className="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-full">
101+
<SmartphoneIcon className="size-5" />
102+
</span>
103+
<p className="text-muted-foreground text-xs font-medium">
104+
Paso {NUMERO[paso]} de 3
105+
</p>
106+
<DialogTitle>
107+
{paso === 'clave'
108+
? 'Confirme que es usted'
109+
: paso === 'escanear'
110+
? 'Escanee el código con su app'
111+
: 'Guarde sus códigos de respaldo'}
112+
</DialogTitle>
113+
<DialogDescription>
114+
{paso === 'clave'
115+
? 'Escriba su contraseña para cambiar cómo se entra a su cuenta.'
116+
: paso === 'escanear'
117+
? 'Sirve Google Authenticator, Microsoft Authenticator, 1Password u otra compatible.'
118+
: 'Si pierde el teléfono, cada código le deja entrar una vez. No se volverán a mostrar.'}
119+
</DialogDescription>
120+
</DialogHeader>
121+
122+
{paso === 'clave' && (
123+
<form
124+
id="alta-app"
125+
className="space-y-2"
126+
noValidate
127+
onSubmit={(evento) => {
128+
evento.preventDefault()
129+
empezar.mutate()
130+
}}
131+
>
132+
<Label htmlFor="clave-app">Contraseña actual</Label>
133+
<PasswordInput
134+
id="clave-app"
135+
autoComplete="current-password"
136+
autoFocus
137+
value={clave}
138+
aria-invalid={Boolean(error)}
139+
onChange={(evento) => {
140+
setClave(evento.target.value)
141+
setError(undefined)
142+
}}
143+
/>
144+
<FieldError message={error} />
145+
</form>
146+
)}
147+
148+
{paso === 'escanear' && alta && (
149+
<div className="space-y-5">
150+
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
151+
<CodigoQR valor={alta.uri} etiqueta="Código QR para su app autenticadora" />
152+
<ol className="text-muted-foreground space-y-2.5 text-sm">
153+
{[
154+
'Abra la app y toque «Añadir cuenta» o «+».',
155+
'Elija escanear y apunte a este código.',
156+
'Escriba abajo el código de seis dígitos que aparece.',
157+
].map((texto, indice) => (
158+
<li key={texto} className="flex gap-2.5">
159+
<span className="bg-muted text-foreground grid size-5 shrink-0 place-items-center rounded-full text-xs font-medium">
160+
{indice + 1}
161+
</span>
162+
{texto}
163+
</li>
164+
))}
165+
</ol>
166+
</div>
167+
168+
{/* En el teléfono no se escanea la propia pantalla: se abre la app */}
169+
<Button asChild variant="outline" className="hidden w-full pointer-coarse:flex">
170+
<a href={alta.uri}>
171+
<ExternalLinkIcon />
172+
Abrir en mi app autenticadora
173+
</a>
174+
</Button>
175+
176+
<ClaveManual clave={alta.secret} />
177+
178+
<div className="space-y-2">
179+
<Label htmlFor="codigo-app">Código de la app</Label>
180+
<CodeInput
181+
id="codigo-app"
182+
autoFocus
183+
value={codigo}
184+
disabled={confirmar.isPending}
185+
aria-invalid={Boolean(error)}
186+
onValueChange={alTeclear}
187+
/>
188+
<FieldError message={error} />
189+
</div>
190+
</div>
191+
)}
192+
193+
{paso === 'respaldo' && (
194+
<RecoveryCodes
195+
codigos={respaldos}
196+
correo={correo}
197+
guardados={guardados}
198+
onGuardados={setGuardados}
199+
/>
200+
)}
201+
202+
<DialogFooter>
203+
{paso === 'clave' && (
204+
<Button
205+
type="submit"
206+
form="alta-app"
207+
disabled={clave.length === 0 || empezar.isPending}
208+
>
209+
{empezar.isPending && <Loader2Icon className="animate-spin" />}
210+
Continuar
211+
</Button>
212+
)}
213+
{paso === 'escanear' && (
214+
<Button
215+
disabled={codigo.length < 6 || confirmar.isPending}
216+
onClick={() => confirmar.mutate(codigo)}
217+
>
218+
{confirmar.isPending && <Loader2Icon className="animate-spin" />}
219+
Activar
220+
</Button>
221+
)}
222+
{paso === 'respaldo' ? (
223+
<Button disabled={!guardados} onClick={terminar}>
224+
<CheckIcon />
225+
Listo
226+
</Button>
227+
) : (
228+
<Button variant="outline" onClick={onVolver}>
229+
Volver
230+
</Button>
231+
)}
232+
</DialogFooter>
233+
</>
234+
)
235+
}
236+
237+
/** Sin cámara: la clave a mano. */
238+
function ClaveManual({ clave }: { clave: string }) {
239+
const [abierta, setAbierta] = useState(false)
240+
const [copiada, setCopiada] = useState(false)
241+
242+
const copiar = async () => {
243+
try {
244+
await navigator.clipboard.writeText(clave)
245+
setCopiada(true)
246+
window.setTimeout(() => setCopiada(false), 1600)
247+
} catch {
248+
// Sin portapapeles: la clave sigue a la vista
249+
}
250+
}
251+
252+
return (
253+
<div className="rounded-lg border">
254+
<button
255+
type="button"
256+
aria-expanded={abierta}
257+
onClick={() => setAbierta((actual) => !actual)}
258+
className="focus-visible:outline-ring flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2.5 text-left text-sm focus-visible:-outline-offset-2 focus-visible:outline-1"
259+
>
260+
¿No puede escanearlo?
261+
<ChevronDownIcon
262+
className={cn(
263+
'text-muted-foreground size-4 transition-transform',
264+
abierta && 'rotate-180',
265+
)}
266+
/>
267+
</button>
268+
{abierta && (
269+
<div className="space-y-3 border-t px-3 py-3">
270+
<p className="text-muted-foreground text-xs">
271+
En la app, elija «Introducir clave» y escriba esta, basada en el tiempo.
272+
</p>
273+
<div className="flex items-center gap-2">
274+
<code className="font-machine bg-muted/60 min-w-0 flex-1 rounded-md px-3 py-2 text-sm break-words">
275+
{agruparClave(clave)}
276+
</code>
277+
<Button type="button" variant="outline" size="icon" onClick={copiar}>
278+
{copiada ? <CheckIcon className="text-success" /> : <CopyIcon />}
279+
<span className="sr-only">{copiada ? 'Copiada' : 'Copiar la clave'}</span>
280+
</Button>
281+
</div>
282+
</div>
283+
)}
284+
</div>
285+
)
286+
}

0 commit comments

Comments
 (0)