feat(web): страница входа по доске AuthLogin
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 1m0s
quality / docker-check (push) Successful in 1m31s
CD / quality (push) Successful in 4m35s
CD / publish (push) Successful in 1m46s
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 1m0s
quality / docker-check (push) Successful in 1m31s
CD / quality (push) Successful in 4m35s
CD / publish (push) Successful in 1m46s
Две колонки: описание портала с перечнем возможностей и форма во Frame; плашка SSO показывает приложение, в которое вернёмся после входа. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
11a0b56be5
commit
5c695c0ae0
1 file changed
+77
-23
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
|
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
browserSupportsWebAuthn,
|
browserSupportsWebAuthn,
|
||||||
browserSupportsWebAuthnAutofill,
|
browserSupportsWebAuthnAutofill,
|
||||||
@@ -35,6 +35,15 @@ import { ApiError } from '@/lib/api-client'
|
|||||||
import { login, meQueryKey } from '@/queries/auth'
|
import { login, meQueryKey } from '@/queries/auth'
|
||||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||||
import { AuthLogo } from '@/components/auth-logo'
|
import { AuthLogo } from '@/components/auth-logo'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
|
||||||
|
const PERKS = [
|
||||||
|
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
|
||||||
|
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
|
||||||
|
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function PortalLoginForm() {
|
export function PortalLoginForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -153,23 +162,68 @@ export function PortalLoginForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returnHost = (() => {
|
||||||
|
try {
|
||||||
|
return search.return_to ? new URL(search.return_to).host : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-svh min-w-0 flex-col justify-center py-8">
|
<section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
|
||||||
<div className="mx-auto flex w-full max-w-90 flex-col gap-6 px-4">
|
<div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<div className="hidden flex-col gap-6 md:flex">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<AuthLogo />
|
<AuthLogo />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
<b className="text-base font-semibold">Auth Portal</b>
|
||||||
Auth Portal
|
<span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Единый вход в приложения shnt.top
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
|
||||||
|
Один вход для всех сервисов shnt.top
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
|
||||||
|
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
|
||||||
|
Technitium DNS без повторного ввода пароля.
|
||||||
|
</p>
|
||||||
|
<Frame spacing="sm" className="max-w-[420px]">
|
||||||
|
<FramePanel className="p-1">
|
||||||
|
{PERKS.map((perk) => (
|
||||||
|
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
|
||||||
|
<IconTile variant="outline" aria-hidden className="shrink-0">
|
||||||
|
<perk.icon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<b className="text-sm font-medium">{perk.title}</b>
|
||||||
|
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
|
||||||
|
<FrameHeader className="gap-1 px-5 pt-5 pb-1">
|
||||||
|
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
|
||||||
|
<FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
|
||||||
|
{returnHost ? (
|
||||||
|
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
SSO
|
||||||
|
</Badge>
|
||||||
|
<span>
|
||||||
|
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive" role="alert">
|
||||||
<AlertTitle>Ошибка входа</AlertTitle>
|
<AlertTitle>Ошибка входа</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -185,20 +239,21 @@ export function PortalLoginForm() {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="username webauthn"
|
autoComplete="username webauthn"
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
className="bg-background"
|
aria-invalid={error ? true : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field className="gap-2">
|
<Field className="gap-2">
|
||||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||||
<InputGroup className="bg-background w-full">
|
<InputGroup className="w-full">
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Пароль"
|
placeholder="Пароль"
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<InputGroupAddon align="inline-end">
|
<InputGroupAddon align="inline-end">
|
||||||
@@ -221,24 +276,23 @@ export function PortalLoginForm() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{passkeySupported ? (
|
{passkeySupported ? (
|
||||||
<div className="flex flex-col gap-3">
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
<span className="text-muted-foreground text-xs">или</span>
|
<span className="text-muted-foreground text-xs">или</span>
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => void handlePasskeyClick()}
|
|
||||||
>
|
|
||||||
<FingerprintIcon aria-hidden="true" />
|
<FingerprintIcon aria-hidden="true" />
|
||||||
Войти с passkey
|
Войти с passkey
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<span className="text-muted-foreground text-center text-xs">
|
||||||
|
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user