- демо-блоки 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 в описание стека
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
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>
|
||
)
|
||
}
|