@@ -1,7 +1,7 @@
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 { EyeIcon , EyeOffIcon , FingerprintIcon , HistoryIcon , ShieldIcon } from 'lucide-react'
import {
browserSupportsWebAuthn ,
browserSupportsWebAuthnAutofill ,
@@ -35,6 +35,15 @@ import { ApiError } from '@/lib/api-client'
import { login , meQueryKey } from '@/queries/auth'
import { webauthnLogin , webauthnLoginOptions } from '@/queries/webauthn'
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() {
const navigate = useNavigate ( )
@@ -153,92 +162,137 @@ export function PortalLoginForm() {
}
}
const returnHost = ( ( ) = > {
try {
return search . return_to ? new URL ( search . return_to ) . host : null
} catch {
return null
}
} ) ( )
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 >
< section className = "bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8" >
< div className = "grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2 " >
< div className = "hidden flex-col gap-6 md:flex " >
< div className = "flex items-center gap-3" >
< AuthLogo / >
< div className = "flex flex-col " >
< b className = "text-base font-semibold" > Auth Portal < / b >
< span className = "text-muted-foreground text-[13px]" > auth . shnt . top < / span >
< / 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 >
{ error ? (
< Alert variant = "destructive " >
< AlertTitle > О ш и б к а в х о д а < / AlertTitle >
< AlertDescription > { error } < / AlertDescription >
< / Alert >
) : null }
< 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 }
< 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 >
{ error ? (
< Alert variant = "destructive" role = "alert ">
< AlertTitle > О ш и б к а в х о д а < / AlertTitle >
< AlertDescription > { error } < / AlertDescription >
< / Alert >
) : null }
< 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 >
< 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] "
aria-invalid = { error ? true : undefined }
required
/ >
< / Field >
< Button type = "submit" className = "w-full" disabled = { pending } >
{ pending ? 'Вход…' : 'Войти' }
< / Button >
< / form >
< Field className = "gap-2" >
< FieldLabel htmlFor = "password" > П а р о л ь < / FieldLabel >
< InputGroup className = "w-full" >
< InputGroupInput
id = "password"
name = "password"
type = { showPassword ? 'text' : 'password' }
autoComplete = "current-password"
placeholder = "Пароль"
aria-invalid = { error ? true : undefined }
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 >
{ 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 }
< Button type = "submit" className = "w-full" disabled = { pending } >
{ pending ? 'Вход…' : 'Войти' }
< / Button >
< / form >
{ passkeySupported ? (
< >
< 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 >
< span className = "text-muted-foreground text-center text-xs" >
П а р о л ь о с т а ё т с я з а п а с н ы м в х о д о м . Passkey д о б а в л я е т с я в р а з д е л е « Б е з о п а с н о с т ь » .
< / span >
< / >
) : null }
< / FramePanel >
< / Frame >
< / div >
< / section >
)