feat(auth): добавить вход по passkey
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s

Альтернатива паролю на портале; SSO приложений без изменений.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 10:24:46 +07:00
co-authored by Cursor
parent f76b89c1df
commit 46dc2f714c
26 changed files with 1820 additions and 72 deletions
+130 -28
View File
@@ -1,8 +1,20 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
WebAuthnAbortService,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
import {
buildSsoRedirectUrl,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
type LoginResponse,
} from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
@@ -12,6 +24,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import {
Alert,
AlertDescription,
@@ -20,6 +33,7 @@ import {
import { ensureAuthConfig, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
export function PortalLoginForm() {
@@ -29,42 +43,110 @@ export function PortalLoginForm() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
async function applySession(res: LoginResponse) {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const returnTo = search.return_to
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
}
async function runPasskeyLogin() {
const { challenge_id, options } = await webauthnLoginOptions()
const assertion = await startAuthentication({
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
})
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
await applySession(res)
}
useEffect(() => {
if (!browserSupportsWebAuthn()) return
setPasskeySupported(true)
let cancelled = false
async function startConditional() {
if (!(await browserSupportsWebAuthnAutofill())) return
try {
const { challenge_id, options } = await webauthnLoginOptions()
if (cancelled) return
const assertion = await startAuthentication({
optionsJSON:
options as unknown as PublicKeyCredentialRequestOptionsJSON,
useBrowserAutofill: true,
})
if (cancelled) return
setPending(true)
setError(null)
const res = await webauthnLogin(
challenge_id,
assertion,
search.return_to,
)
await applySession(res)
} catch {
/* abort / unsupported / user dismissed */
} finally {
if (!cancelled) setPending(false)
}
}
void startConditional()
return () => {
cancelled = true
WebAuthnAbortService.cancelCeremony()
}
// Login page mount only — return_to is stable for the visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
const form = new FormData(event.currentTarget)
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
const returnTo = search.return_to
try {
const res = await login(email, password, returnTo)
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const res = await login(email, password, search.return_to)
await applySession(res)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
} finally {
setPending(false)
}
}
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
async function handlePasskeyClick() {
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
try {
await runPasskeyLogin()
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти',
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
)
} finally {
setPending(false)
@@ -101,7 +183,7 @@ export function PortalLoginForm() {
id="email"
name="email"
type="email"
autoComplete="username"
autoComplete="username webauthn"
placeholder="[email protected]"
className="bg-background"
required
@@ -137,6 +219,26 @@ export function PortalLoginForm() {
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
{passkeySupported ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={pending}
onClick={() => void handlePasskeyClick()}
>
<FingerprintIcon aria-hidden="true" />
Войти с passkey
</Button>
</div>
) : null}
</div>
</section>
)