Compare commits
3
Commits
v1.6.0
...
140ffdb7b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
140ffdb7b1 | ||
|
|
0948455d15 | ||
|
|
e7b34ce6ae |
No files matched your search
+49
-9
@@ -1,4 +1,4 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
allPermissionKeys,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
closeDb,
|
||||
createDb,
|
||||
getUserApps,
|
||||
healthCheck,
|
||||
@@ -35,6 +36,9 @@ import { webauthnRoutes } from './routes/webauthn.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
||||
|
||||
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||
const API_PREFIXES = ['/api', '/oauth', '/.well-known']
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
config: AppConfig
|
||||
@@ -97,11 +101,42 @@ export async function buildApp(opts: {
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
trustProxy: true,
|
||||
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||
keepAliveTimeout: 65_000,
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 30_000,
|
||||
bodyLimit: 1_048_576,
|
||||
})
|
||||
|
||||
app.decorate('config', config)
|
||||
app.decorate('db', db)
|
||||
app.decorate('sqlite', sqlite)
|
||||
app.addHook('onClose', async () => {
|
||||
closeDb(sqlite)
|
||||
})
|
||||
|
||||
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
|
||||
const status =
|
||||
err.name === 'ZodError'
|
||||
? 400
|
||||
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
|
||||
? (err as FastifyError).statusCode!
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
req.log.error(err)
|
||||
return reply.status(500).send({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
}
|
||||
const code =
|
||||
status === 429
|
||||
? 'RATE_LIMITED'
|
||||
: status === 400
|
||||
? 'BAD_REQUEST'
|
||||
: ((err as FastifyError).code ?? 'ERROR')
|
||||
return reply.status(status).send({ error: { code, message: err.message } })
|
||||
})
|
||||
|
||||
|
||||
await app.register(sensible)
|
||||
await app.register(formbody)
|
||||
@@ -147,15 +182,20 @@ export async function buildApp(opts: {
|
||||
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
|
||||
preCompressed: true,
|
||||
})
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.method === 'GET' && !req.url.startsWith('/api')) {
|
||||
return reply.sendFile('index.html')
|
||||
}
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
})
|
||||
}
|
||||
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (
|
||||
spa &&
|
||||
req.method === 'GET' &&
|
||||
!API_PREFIXES.some((p) => req.url.startsWith(p))
|
||||
) {
|
||||
return reply.sendFile('index.html')
|
||||
}
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export const configSchema = z.object({
|
||||
serverPort: z.coerce.number().int().positive().default(8080),
|
||||
staticDir: z.string().optional(),
|
||||
logLevel: z.string().default('info'),
|
||||
/** Максимум на graceful shutdown, затем принудительный выход. */
|
||||
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
|
||||
isProd: z.boolean(),
|
||||
auditIngestSecret: z.string().min(8).optional(),
|
||||
webauthnRpID: z.string().min(1),
|
||||
@@ -56,6 +58,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
serverPort: env.SERVER_PORT ?? 8080,
|
||||
staticDir: env.STATIC_DIR || undefined,
|
||||
logLevel: env.LOG_LEVEL ?? 'info',
|
||||
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
||||
isProd:
|
||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||
isProd,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export interface ShutdownOptions {
|
||||
timeoutMs: number
|
||||
exit?: (code: number) => void
|
||||
signals?: NodeJS.Signals[]
|
||||
proc?: Pick<NodeJS.Process, 'on' | 'off'>
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
|
||||
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
|
||||
* Возвращает функцию отписки и ручной триггер (для тестов).
|
||||
*/
|
||||
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
|
||||
const exit = opts.exit ?? ((code: number) => process.exit(code))
|
||||
const proc = opts.proc ?? process
|
||||
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
|
||||
let closing: Promise<void> | null = null
|
||||
|
||||
const shutdown = (reason: string, code = 0): Promise<void> => {
|
||||
if (closing) {
|
||||
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
|
||||
exit(1)
|
||||
return closing
|
||||
}
|
||||
app.log.info(`shutdown: ${reason}`)
|
||||
const timer = setTimeout(() => {
|
||||
app.log.error('shutdown: timeout, forcing exit')
|
||||
exit(1)
|
||||
}, opts.timeoutMs)
|
||||
timer.unref()
|
||||
closing = app
|
||||
.close()
|
||||
.then(() => {
|
||||
clearTimeout(timer)
|
||||
exit(code)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
clearTimeout(timer)
|
||||
app.log.error(err, 'shutdown: close failed')
|
||||
exit(1)
|
||||
})
|
||||
return closing
|
||||
}
|
||||
|
||||
const handlers = signals.map((sig) => {
|
||||
const h = () => void shutdown(sig)
|
||||
proc.on(sig, h)
|
||||
return [sig, h] as const
|
||||
})
|
||||
const onFatal = (err: unknown) => {
|
||||
app.log.error(err, 'fatal error')
|
||||
void shutdown('fatal', 1)
|
||||
}
|
||||
proc.on('unhandledRejection', onFatal)
|
||||
proc.on('uncaughtException', onFatal)
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
dispose() {
|
||||
for (const [sig, h] of handlers) proc.off(sig, h)
|
||||
proc.off('unhandledRejection', onFatal)
|
||||
proc.off('uncaughtException', onFatal)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { registerShutdown } from './lib/shutdown.js'
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, '../../../.env'),
|
||||
@@ -31,6 +32,8 @@ for (const path of [
|
||||
const config = loadConfig()
|
||||
const app = await buildApp({ config })
|
||||
|
||||
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||
app.log.info(`auth-portal listening on ${config.serverPort}`)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('error handler', () => {
|
||||
it('необработанная ошибка → 500 без деталей', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__boom', async () => {
|
||||
throw new Error('secret internals')
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
expect(res.body).not.toContain('secret internals')
|
||||
})
|
||||
|
||||
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__teapot', async () => {
|
||||
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||
expect(res.statusCode).toBe(418)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not found', () => {
|
||||
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||
'%s → 404 JSON',
|
||||
async (url) => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
},
|
||||
)
|
||||
|
||||
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.body).toContain('spa')
|
||||
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
function fakeApp(close: () => Promise<void>) {
|
||||
return {
|
||||
close,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||
}
|
||||
|
||||
describe('registerShutdown', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||
let release!: () => void
|
||||
const close = vi.fn(
|
||||
() => new Promise<void>((r) => (release = r)),
|
||||
)
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
proc.emit('SIGINT')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
})
|
||||
|
||||
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(() => new Promise<void>(() => {})),
|
||||
{ timeoutMs: 500, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('ошибка close → выход с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('unhandledRejection', new Error('x'))
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('dispose снимает обработчики', () => {
|
||||
const proc = new EventEmitter()
|
||||
const h = registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit: vi.fn(),
|
||||
proc: proc as never,
|
||||
})
|
||||
h.dispose()
|
||||
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeDb', () => {
|
||||
it('идемпотентно закрывает БД', () => {
|
||||
const { sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||
})
|
||||
|
||||
it('app.close() закрывает БД приложения', async () => {
|
||||
const app = await buildTestApp()
|
||||
await app.ready()
|
||||
expect(app.sqlite.open).toBe(true)
|
||||
await app.close()
|
||||
expect(app.sqlite.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||
|
||||
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
|
||||
headerRow: 'border-b border-border',
|
||||
bodyRow: 'group/row',
|
||||
}
|
||||
|
||||
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||
|
||||
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||
export function cellPad(index: number, count: number) {
|
||||
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||
return DATA_GRID_CELL_PAD
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Column } from '@tanstack/react-table'
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataPageToolbar({
|
||||
leading,
|
||||
segmented,
|
||||
filterControl,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
countLabel,
|
||||
actions,
|
||||
}: {
|
||||
leading?: ReactNode
|
||||
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||
filterControl?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{leading}
|
||||
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||
{filterControl}
|
||||
{onSearchChange ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataGridSortHeader<TData extends object>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<DataGridFeatures, TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (!column.getCanSort()) {
|
||||
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === 'asc' ? (
|
||||
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||
) : sorted === 'desc' ? (
|
||||
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||
) : (
|
||||
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
||||
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -26,12 +24,22 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { StatusBadge } from './bits'
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_LAYOUT,
|
||||
} from './data-page-layout'
|
||||
import {
|
||||
DataGridSortHeader,
|
||||
DataPageCard,
|
||||
DataPageToolbar,
|
||||
} from './data-page'
|
||||
import type { Tone } from './tone'
|
||||
import type { RowMark } from './tone'
|
||||
import { TONE_DOT } from './tone'
|
||||
@@ -101,8 +109,7 @@ export function SectionGrid<T extends object>({
|
||||
rowMenu,
|
||||
onReorder,
|
||||
rowStatus,
|
||||
pageSize = 8,
|
||||
footer,
|
||||
pageSize = 50,
|
||||
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||
className,
|
||||
}: {
|
||||
@@ -194,20 +201,22 @@ export function SectionGrid<T extends object>({
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: SELECT_COLUMN_SIZE,
|
||||
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
|
||||
})
|
||||
}
|
||||
for (const col of visibleColumns) {
|
||||
visibleColumns.forEach((col, i) => {
|
||||
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
|
||||
list.push({
|
||||
id: col.id,
|
||||
accessorFn: col.sortValue ?? (() => ''),
|
||||
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
|
||||
cell: ({ row }) => col.cell(row.original),
|
||||
enableSorting: Boolean(col.sortValue),
|
||||
enableResizing: false,
|
||||
size: col.size,
|
||||
meta: { headerTitle: col.title },
|
||||
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
|
||||
})
|
||||
}
|
||||
})
|
||||
list.push({
|
||||
id: '__actions',
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
@@ -242,6 +251,7 @@ export function SectionGrid<T extends object>({
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: ACTIONS_COLUMN_SIZE,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
})
|
||||
return list
|
||||
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||
@@ -277,6 +287,8 @@ export function SectionGrid<T extends object>({
|
||||
const paged = data.length > pageSize
|
||||
const showCards = Boolean(card) && cards
|
||||
|
||||
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
@@ -285,82 +297,72 @@ export function SectionGrid<T extends object>({
|
||||
onRowClick={onRowClick}
|
||||
getRowStatus={rowStatus}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
|
||||
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
|
||||
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
</div>
|
||||
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
||||
{card ? (
|
||||
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||
Таблица
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||
Карточки
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
{searchText ? (
|
||||
<InputGroup className="w-[220px] max-w-full">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
placeholder="Поиск"
|
||||
aria-label={searchPlaceholder}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{columns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||
>
|
||||
<Columns3Icon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
{columns.slice(1).map((c) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={c.id}
|
||||
checked={!hidden[c.id]}
|
||||
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
|
||||
>
|
||||
{c.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0 p-0 shadow-none">
|
||||
{views ? (
|
||||
<Tabs
|
||||
value={String(view)}
|
||||
onValueChange={(v) => {
|
||||
setView(Number(v))
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
|
||||
{views.map((v, i) => (
|
||||
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
|
||||
{v.label} · {rows.filter(v.match).length}
|
||||
<DataPageCard className={className}>
|
||||
<h2 className="sr-only">{title}</h2>
|
||||
<DataPageToolbar
|
||||
segmented={
|
||||
views
|
||||
? {
|
||||
value: String(view),
|
||||
label: description,
|
||||
onChange: (v) => {
|
||||
setView(Number(v))
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
},
|
||||
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
leading={
|
||||
card ? (
|
||||
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||
Таблица
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||
Карточки
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : undefined
|
||||
}
|
||||
search={query}
|
||||
onSearchChange={
|
||||
searchText
|
||||
? (v) => {
|
||||
setQuery(v)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={countLabel}
|
||||
actions={
|
||||
<>
|
||||
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
||||
{columns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||
>
|
||||
<Columns3Icon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
{columns.slice(1).map((c) => (
|
||||
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
|
||||
{c.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{selectable && selectedIds.length > 0 ? (
|
||||
<div
|
||||
role="toolbar"
|
||||
@@ -461,24 +463,13 @@ export function SectionGrid<T extends object>({
|
||||
</ul>
|
||||
) : (
|
||||
<DataGridScrollArea>
|
||||
<div className="pb-3" style={{ minWidth }}>
|
||||
{onReorder ? (
|
||||
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
|
||||
) : (
|
||||
<DataGridTable />
|
||||
)}
|
||||
<div style={{ minWidth }}>
|
||||
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||
</div>
|
||||
</DataGridScrollArea>
|
||||
)}
|
||||
<Separator />
|
||||
<FrameFooter className="flex-wrap justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
|
||||
</span>
|
||||
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
|
||||
</DataPageCard>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ import {
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
|
||||
import { cellPad } from '@/components/portal/data-page-layout'
|
||||
import {
|
||||
DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_LAYOUT,
|
||||
} from '@/components/portal/data-page-layout'
|
||||
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Filters } from '@/components/reui/filters/filters'
|
||||
import {
|
||||
createFilterQuery,
|
||||
@@ -54,14 +61,6 @@ import type {
|
||||
FilterField,
|
||||
FilterQuery,
|
||||
} from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -93,7 +92,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||
|
||||
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
|
||||
return isAdmin ? (
|
||||
<Badge variant="info-outline">Админ</Badge>
|
||||
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
|
||||
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
||||
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
total: users.length,
|
||||
admins: users.filter((u) => u.is_admin).length,
|
||||
disabled: users.filter((u) => u.disabled).length,
|
||||
}),
|
||||
[users],
|
||||
)
|
||||
|
||||
const filterFields: FilterField[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}).map((col, i, all) => {
|
||||
const pad = cellPad(i, all.length)
|
||||
return {
|
||||
...col,
|
||||
meta: {
|
||||
...col.meta,
|
||||
headerClassName: cn(col.meta?.headerClassName, pad),
|
||||
cellClassName: cn(col.meta?.cellClassName, pad),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||
)
|
||||
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
...DATA_GRID_LAYOUT,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle id="page-heading" className="text-balance">
|
||||
Пользователи
|
||||
</FrameTitle>
|
||||
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
|
||||
<span>{stats.total} учётных записей</span>
|
||||
<DotSeparator />
|
||||
<span>{stats.admins} админов</span>
|
||||
{stats.disabled > 0 ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<span>{stats.disabled} отключено</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button type="button" size="default" onClick={onCreateClick}>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Создать
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0 shadow-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
filterControl={
|
||||
<Filters
|
||||
query={query}
|
||||
fields={filterFields}
|
||||
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
|
||||
operatorLabels={FILTER_OPERATORS_RU}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
aria-label="Фильтры"
|
||||
>
|
||||
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon aria-hidden />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
}
|
||||
countLabel={`${filteredData.length} пользователей`}
|
||||
actions={
|
||||
<>
|
||||
{showClearButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={handleClearFilters}
|
||||
disabled={gridLoading}
|
||||
>
|
||||
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
|
||||
<FunnelXIcon aria-hidden />
|
||||
Сбросить
|
||||
</Button>
|
||||
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
aria-label="Настройки таблицы"
|
||||
>
|
||||
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<AdminUsersBulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
roleValue={bulkRole}
|
||||
onRoleChange={setBulkRole}
|
||||
onChangeRole={handleChangeRole}
|
||||
onDeactivate={handleBulkDeactivate}
|
||||
onClear={handleClearSelection}
|
||||
/>
|
||||
<Separator />
|
||||
<Button type="button" size="default" onClick={onCreateClick}>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination sizes={[10, 20, 30]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
}
|
||||
/>
|
||||
{selectedCount > 0 ? (
|
||||
<AdminUsersBulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
roleValue={bulkRole}
|
||||
onRoleChange={setBulkRole}
|
||||
onChangeRole={handleChangeRole}
|
||||
onDeactivate={handleBulkDeactivate}
|
||||
onClear={handleClearSelection}
|
||||
/>
|
||||
) : null}
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
|
||||
</DataPageCard>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
|
||||
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
|
||||
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
@@ -10,112 +7,6 @@ html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
|
||||
html:not(.dark) {
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.205 0.01 264);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.205 0.01 264);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.205 0.01 264);
|
||||
--secondary: oklch(0.955 0.005 264);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.95 0.006 264);
|
||||
--muted-foreground: oklch(0.44 0.02 264);
|
||||
--accent: oklch(0.93 0.01 264);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--border: oklch(0.88 0.008 264);
|
||||
--input: oklch(0.86 0.008 264);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--sidebar: oklch(0.985 0.003 264);
|
||||
--sidebar-foreground: oklch(0.205 0.01 264);
|
||||
--sidebar-accent: oklch(0.93 0.01 264);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.88 0.008 264);
|
||||
--invert: oklch(0.9 0.01 264);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: oklch(0.45 0.2 27);
|
||||
--success: oklch(0.6 0.15 163);
|
||||
--success-foreground: oklch(0.4 0.1 163);
|
||||
--warning: oklch(0.75 0.16 80);
|
||||
--warning-foreground: oklch(0.45 0.1 70);
|
||||
--info: oklch(0.55 0.23 293);
|
||||
--info-foreground: oklch(0.42 0.2 293);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.252 0.01 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.252 0.01 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--invert: oklch(0.37 0.013 285.805);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--success-foreground: oklch(0.596 0.145 163.225);
|
||||
--warning: oklch(0.795 0.184 86.047);
|
||||
--warning-foreground: oklch(0.681 0.162 75.834);
|
||||
--info: oklch(0.606 0.25 292.717);
|
||||
--info-foreground: oklch(0.541 0.281 293.009);
|
||||
}
|
||||
|
||||
html[data-contrast='high'] {
|
||||
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
|
||||
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
|
||||
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
|
||||
--ring: oklch(0.35 0.2 264);
|
||||
}
|
||||
|
||||
html.dark[data-contrast='high'] {
|
||||
--background: oklch(0 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--sidebar: oklch(0.1 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--success: oklch(0.82 0.19 150);
|
||||
--success-foreground: oklch(0.9 0.15 150);
|
||||
--warning: oklch(0.9 0.17 95);
|
||||
--warning-foreground: oklch(0.95 0.12 95);
|
||||
--destructive: oklch(0.78 0.2 25);
|
||||
--destructive-foreground: oklch(0.88 0.12 25);
|
||||
--info: oklch(0.8 0.15 300);
|
||||
--info-foreground: oklch(0.9 0.1 300);
|
||||
--card: oklch(0.12 0 0);
|
||||
--popover: oklch(0.1 0 0);
|
||||
--muted: oklch(0.22 0 0);
|
||||
--muted-foreground: oklch(0.86 0 0);
|
||||
--border: oklch(1 0 0 / 55%);
|
||||
--input: oklch(1 0 0 / 60%);
|
||||
--ring: oklch(0.85 0.17 95);
|
||||
}
|
||||
|
||||
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
||||
html {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
html body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
@@ -130,16 +21,3 @@ html #main-content :focus-visible {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
|
||||
html [data-slot='data-grid-table'] thead th,
|
||||
html [data-slot='data-grid-table'] thead th :is(span, button, div) {
|
||||
font-size: 0.75rem;
|
||||
background: color-mix(in oklch, var(--muted) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Затемнение под панелями без размытия — как на досках. */
|
||||
html :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
|
||||
background: oklch(0 0 0 / 55%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import { useCallback, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { ShieldCheckIcon, UserCheckIcon, UserXIcon, UsersIcon } from 'lucide-react'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
import { PageHead } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
||||
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
@@ -173,8 +177,21 @@ function AdminUsersPage() {
|
||||
[invalidateUsers],
|
||||
)
|
||||
|
||||
const admins = users.filter((u) => u.is_admin).length
|
||||
const disabled = users.filter((u) => u.disabled).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Пользователи" sub="Учётные записи портала · роли, доступ к приложениям, passkeys" />
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('all', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'учётных записей' }),
|
||||
kpi('adm', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'ok', badge: 'is_admin' }),
|
||||
kpi('act', 'Активных', String(users.length - disabled), { icon: UserCheckIcon, badge: 'могут войти' }),
|
||||
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, tone: disabled ? 'wn' : 'neutral', badge: 'disabled' }),
|
||||
]}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
|
||||
@@ -54,6 +54,8 @@ services:
|
||||
pull_policy: always
|
||||
container_name: auth-portal
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
stop_grace_period: 15s
|
||||
depends_on:
|
||||
- traefik
|
||||
# No host ports — only Traefik publishes 80/443.
|
||||
|
||||
@@ -52,6 +52,7 @@ ENV NODE_ENV=production \
|
||||
COPY --from=build --chown=node:node /out ./
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
STOPSIGNAL SIGTERM
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
|
||||
@@ -23,6 +23,8 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
|
||||
@@ -215,6 +215,17 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
`)
|
||||
}
|
||||
|
||||
/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */
|
||||
export function closeDb(sqlite: Sqlite): void {
|
||||
if (!sqlite.open) return
|
||||
try {
|
||||
if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)')
|
||||
sqlite.pragma('optimize')
|
||||
} finally {
|
||||
sqlite.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function healthCheck(sqlite: Sqlite): void {
|
||||
sqlite.prepare('SELECT 1').get()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user