3 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 5c695c0ae0 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
Две колонки: описание портала с перечнем возможностей и форма во Frame; плашка SSO показывает приложение, в которое вернёмся после входа.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:48:43 +07:00
DenozordecandClaude Sonnet 5.5 11a0b56be5 fix(web): контент app-shell на всю ширину, как в MikrotikManager
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 47s
quality / docker-check (push) Successful in 1m15s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m37s
Убран max-w-[1480px] mx-auto у контейнера содержимого в portal-shell.
Ширина сайдбара (240px / 18rem / 3rem), высота шапки (min-h-12) и
отступы (px-4 md:px-6, py-4 md:py-5) уже совпадали.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:16:02 +07:00
DenozordecandClaude Sonnet 5.5 09c9531ad9 feat(ui): палитра токенов как в MikrotikManager (light/dark)
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 46s
quality / docker-check (push) Successful in 1m19s
CD / quality (push) Successful in 2m13s
CD / publish (push) Successful in 1m21s
packages/ui/src/styles/globals.css: значения :root/.dark перенесены из
MikrotikManager app/globals.css (background, card, primary, secondary, muted,
accent, destructive, border, input, ring, chart-1..5, sidebar-*, success/info/
warning/invert). Добавлены токены MM: chart-line-1..4, chart-rx/tx, status-*
(online/offline/degraded + fg/bg). Шкала радиусов -sm..-4xl как в MM
(множители от --radius 0.625rem). Имена токенов ReUI/shadcn сохранены,
--focus* оставлены (используются ReUI).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:11:29 +07:00
3 changed files with 209 additions and 122 deletions

No files matched your search

@@ -200,7 +200,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
<PortalSidebar /> <PortalSidebar />
<SidebarInset id="main-content" className="min-w-0"> <SidebarInset id="main-content" className="min-w-0">
<PortalHeader /> <PortalHeader />
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div> <div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
</SidebarInset> </SidebarInset>
<CommandPalette /> <CommandPalette />
</SidebarProvider> </SidebarProvider>
+133 -79
View File
@@ -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,92 +162,137 @@ 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">
<AuthLogo /> <div className="flex items-center gap-3">
<div className="flex flex-col gap-1"> <AuthLogo />
<h1 className="text-xl font-semibold tracking-tight"> <div className="flex flex-col">
Auth Portal <b className="text-base font-semibold">Auth Portal</b>
</h1> <span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
<p className="text-muted-foreground text-sm"> </div>
Единый вход в приложения shnt.top
</p>
</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> </div>
{error ? ( <Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
<Alert variant="destructive"> <FrameHeader className="gap-1 px-5 pt-5 pb-1">
<AlertTitle>Ошибка входа</AlertTitle> <FrameTitle className="text-xl">Вход в портал</FrameTitle>
<AlertDescription>{error}</AlertDescription> <FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
</Alert> </FrameHeader>
) : null} <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}> {error ? (
<FieldGroup className="gap-3.5"> <Alert variant="destructive" role="alert">
<Field className="gap-2"> <AlertTitle>Ошибка входа</AlertTitle>
<FieldLabel htmlFor="email">Email</FieldLabel> <AlertDescription>{error}</AlertDescription>
<Input </Alert>
id="email" ) : null}
name="email"
type="email"
autoComplete="username webauthn"
placeholder="[email protected]"
className="bg-background"
required
/>
</Field>
<Field className="gap-2"> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldLabel htmlFor="password">Пароль</FieldLabel> <FieldGroup className="gap-3.5">
<InputGroup className="bg-background w-full"> <Field className="gap-2">
<InputGroupInput <FieldLabel htmlFor="email">Email</FieldLabel>
id="password" <Input
name="password" id="email"
type={showPassword ? 'text' : 'password'} name="email"
autoComplete="current-password" type="email"
placeholder="Пароль" autoComplete="username webauthn"
required placeholder="[email protected]"
/> aria-invalid={error ? true : undefined}
<InputGroupAddon align="inline-end"> required
<InputGroupButton />
type="button" </Field>
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}> <Field className="gap-2">
{pending ? 'Вход…' : 'Войти'} <FieldLabel htmlFor="password">Пароль</FieldLabel>
</Button> <InputGroup className="w-full">
</form> <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 ? ( <Button type="submit" className="w-full" disabled={pending}>
<div className="flex flex-col gap-3"> {pending ? 'Вход…' : 'Войти'}
<div className="flex items-center gap-3"> </Button>
<Separator className="flex-1" /> </form>
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" /> {passkeySupported ? (
</div> <>
<Button <div className="flex items-center gap-3">
type="button" <Separator className="flex-1" />
variant="outline" <span className="text-muted-foreground text-xs">или</span>
className="w-full" <Separator className="flex-1" />
disabled={pending} </div>
onClick={() => void handlePasskeyClick()} <Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
> <FingerprintIcon aria-hidden="true" />
<FingerprintIcon aria-hidden="true" /> Войти с passkey
Войти с passkey </Button>
</Button> <span className="text-muted-foreground text-center text-xs">
</div> Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
) : null} </span>
</>
) : null}
</FramePanel>
</Frame>
</div> </div>
</section> </section>
) )
+75 -42
View File
@@ -8,37 +8,37 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.141 0.006 264.0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.141 0.006 264.0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.141 0.006 264.0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.520 0.010 264.0);
--accent: oklch(0.97 0 0); --accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.904 0.006 264.0);
--input: oklch(0.922 0 0); --input: oklch(0.904 0.006 264.0);
--ring: oklch(0.708 0 0); --ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.546 0.215 264.376);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.527 0.154 150.069);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.625 0.162 41.500);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.591 0.224 296.400);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.577 0.245 27.325);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.152 0.010 264.376);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.920 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.240 0.012 264.376);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.950 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-800); --destructive-foreground: var(--color-red-800);
--success: var(--color-emerald-500); --success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900); --success-foreground: var(--color-emerald-900);
@@ -50,40 +50,55 @@
--invert-foreground: var(--color-zinc-50); --invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500); --focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-900); --focus-foreground: var(--color-blue-900);
--chart-line-1: oklch(0.527 0.154 150.069);
--chart-line-2: oklch(0.546 0.215 264.376);
--chart-line-3: oklch(0.625 0.162 41.500);
--chart-line-4: oklch(0.591 0.224 296.400);
--chart-rx: oklch(0.527 0.154 150.069);
--chart-tx: oklch(0.546 0.215 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.437 0.149 150.069);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
--status-offline-fg: oklch(0.505 0.213 27.325);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
--status-degraded-fg: oklch(0.555 0.163 58.318);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.252 0.010 264.376);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.252 0.010 264.376);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.269 0 0); --accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.637 0.222 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.170 151.328);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.713 0.176 47.600);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.714 0.173 303.900);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.704 0.191 22.216);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-600); --destructive-foreground: var(--color-red-600);
--success: var(--color-emerald-500); --success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600); --success-foreground: var(--color-emerald-600);
@@ -95,6 +110,21 @@
--invert-foreground: var(--color-zinc-50); --invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500); --focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-600); --focus-foreground: var(--color-blue-600);
--chart-line-1: oklch(0.696 0.170 151.328);
--chart-line-2: oklch(0.637 0.222 264.376);
--chart-line-3: oklch(0.713 0.176 47.600);
--chart-line-4: oklch(0.714 0.173 303.900);
--chart-rx: oklch(0.696 0.170 151.328);
--chart-tx: oklch(0.637 0.222 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.696 0.170 151.328);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
--status-offline-fg: oklch(0.704 0.191 22.216);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
--status-degraded-fg: oklch(0.769 0.149 70.080);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
} }
@theme inline { @theme inline {
@@ -129,10 +159,13 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-invert-foreground: var(--invert-foreground); --color-invert-foreground: var(--invert-foreground);
--color-invert: var(--invert); --color-invert: var(--invert);
--color-warning-foreground: var(--warning-foreground); --color-warning-foreground: var(--warning-foreground);