First Commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { 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, isReturnToAllowed } 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 {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||
|
||||
/** Dev default matches auth-portal .env.example; prod should pass via Vite if needed. */
|
||||
const RETURN_TO_ALLOWLIST =
|
||||
import.meta.env.VITE_RETURN_TO_ALLOWLIST ??
|
||||
'.shnt.top,localhost,http://localhost:5173'
|
||||
|
||||
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)
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
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)
|
||||
|
||||
if (
|
||||
returnTo &&
|
||||
isReturnToAllowed(returnTo, RETURN_TO_ALLOWLIST)
|
||||
) {
|
||||
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' })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти',
|
||||
)
|
||||
} 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"
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user