Files
auth-portal/apps/web/src/components/portal-login-form.tsx
T
Denozordec e107af8e43 refactor(web): удалить мёртвый код (~5.9К строк)
- демо-блоки blocks/ (sheet-9, solution-users-1/6, settings-5, auth-18) —
  не импортировались; auth-logo перенесён в components/auth-logo.tsx
- варианты грида data-grid-table-dnd/-dnd-rows/-virtual и ui/svgs — не импортировались
- убраны исключения blocks из tsconfig и eslint
- AGENTS.md: добавить apps/api/db/shared в описание стека
2026-09-23 14:51:25 +07:00

246 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
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'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
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/auth-logo'
export function PortalLoginForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const search = useSearch({ from: '/' }) as { return_to?: string }
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') ?? '')
try {
const res = await login(email, password, search.return_to)
await applySession(res)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
} finally {
setPending(false)
}
}
async function handlePasskeyClick() {
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
try {
await runPasskeyLogin()
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
)
} finally {
setPending(false)
}
}
return (
<section className="flex min-h-svh min-w-0 flex-col justify-center py-8">
<div className="mx-auto flex w-full max-w-90 flex-col gap-6 px-4">
<div className="flex flex-col items-center gap-3 text-center">
<AuthLogo />
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight">
Auth Portal
</h1>
<p className="text-muted-foreground text-sm">
Единый вход в приложения shnt.top
</p>
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldGroup className="gap-3.5">
<Field className="gap-2">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
autoComplete="username webauthn"
placeholder="[email protected]"
className="bg-background"
required
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="password">Пароль</FieldLabel>
<InputGroup className="bg-background w-full">
<InputGroupInput
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Пароль"
required
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full" disabled={pending}>
{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>
)
}