diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 50502c5..336cdc6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,7 @@ import type { AppConfig } from './config.js' import { TelemtClient } from './services/telemt-client.js' import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js' import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js' +import { podRoutes } from './routes/pod.js' declare module 'fastify' { interface FastifyInstance { @@ -82,6 +83,7 @@ export async function buildApp(opts: { await app.register(telemtRoutes) await app.register(fleetRoutes) await app.register(agentProtocolRoutes) + await app.register(podRoutes) app.get('/install-agent.sh', async (_request, reply) => { const candidates = [ diff --git a/apps/api/src/plugins/auth-guards.ts b/apps/api/src/plugins/auth-guards.ts index c0aec48..f626c66 100644 --- a/apps/api/src/plugins/auth-guards.ts +++ b/apps/api/src/plugins/auth-guards.ts @@ -8,6 +8,11 @@ export interface AuthOperator { role: string } +export interface PodParent { + username: string + role: 'pod_parent' +} + declare module '@fastify/jwt' { interface FastifyJWT { payload: { sub: string; username: string; role: string } @@ -22,6 +27,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) return reply.code(401).send({ error: { code: 'unauthorized', message: 'Требуется вход' } }) } + if (request.user.role === 'pod_parent') { + return reply.code(401).send({ + error: { code: 'unauthorized', message: 'Требуется вход оператора' }, + }) + } + const row = request.server.db .select() .from(operators) @@ -42,3 +53,26 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) export function getOperator(request: FastifyRequest): AuthOperator { return (request as FastifyRequest & { operator: AuthOperator }).operator } + +export async function requirePodAuth(request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: { code: 'unauthorized', message: 'Требуется вход' } }) + } + + if (request.user.role !== 'pod_parent' || !request.user.username) { + return reply.code(401).send({ + error: { code: 'unauthorized', message: 'Требуется сессия /pod' }, + }) + } + + ;(request as FastifyRequest & { podParent: PodParent }).podParent = { + username: request.user.username, + role: 'pod_parent', + } +} + +export function getPodParent(request: FastifyRequest): PodParent { + return (request as FastifyRequest & { podParent: PodParent }).podParent +} diff --git a/apps/api/src/routes/pod.ts b/apps/api/src/routes/pod.ts new file mode 100644 index 0000000..a4807f0 --- /dev/null +++ b/apps/api/src/routes/pod.ts @@ -0,0 +1,493 @@ +import type { FastifyInstance } from 'fastify' +import { count, eq } from 'drizzle-orm' +import { z } from 'zod' +import { userHierarchy, userPodSettings } from '@telemt/db' +import { getOperator, getPodParent, requireAuth, requirePodAuth } from '../plugins/auth-guards.js' +import { + fetchTelemtUsers, + resolveUserBySecret, + type TelemtUserInfo, +} from '../services/pod-auth.js' + +const sessionBodySchema = z.object({ + secretOrLink: z.string().min(1), +}) + +const createChildBodySchema = z.object({ + username: z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_.-]+$/), + secret: z + .string() + .regex(/^[0-9a-fA-F]{32}$/) + .optional(), +}) + +const podSettingsBodySchema = z.object({ + canCreateChildren: z.boolean(), + maxChildren: z.number().int().min(0).max(10_000), +}) + +function nowIso(): string { + return new Date().toISOString() +} + +function defaultPodSettings(username: string) { + return { + username, + canCreateChildren: false, + maxChildren: 0, + updatedAt: nowIso(), + } +} + +function getPodSettingsRow( + app: FastifyInstance, + username: string, +): { + username: string + canCreateChildren: boolean + maxChildren: number + updatedAt: string +} { + const row = app.db + .select() + .from(userPodSettings) + .where(eq(userPodSettings.username, username)) + .get() + if (!row) return defaultPodSettings(username) + return { + username: row.username, + canCreateChildren: Boolean(row.canCreateChildren), + maxChildren: row.maxChildren, + updatedAt: row.updatedAt, + } +} + +function countChildren(app: FastifyInstance, parentUsername: string): number { + const row = app.db + .select({ value: count() }) + .from(userHierarchy) + .where(eq(userHierarchy.parentUsername, parentUsername)) + .get() + return Number(row?.value ?? 0) +} + +function isChildUser(app: FastifyInstance, username: string): boolean { + const row = app.db + .select() + .from(userHierarchy) + .where(eq(userHierarchy.childUsername, username)) + .get() + return Boolean(row) +} + +function remainingSlots( + canCreateChildren: boolean, + maxChildren: number, + childrenCount: number, +): number { + if (!canCreateChildren) return 0 + return Math.max(0, maxChildren - childrenCount) +} + +function assertStandaloneTelemt(app: FastifyInstance): boolean { + return Boolean(app.config.telemtApiUrl) +} + +export async function podRoutes(app: FastifyInstance) { + app.post( + '/api/pod/session', + { + config: { + rateLimit: { max: 20, timeWindow: '1 minute' }, + }, + }, + async (request, reply) => { + if (!assertStandaloneTelemt(app)) { + return reply.code(503).send({ + error: { code: 'telemt_unavailable', message: 'Telemt API не настроен' }, + }) + } + + const parsed = sessionBodySchema.safeParse(request.body) + if (!parsed.success) { + return reply.code(400).send({ + error: { code: 'validation_error', message: 'Укажите secret или ссылку tg://proxy' }, + }) + } + + let user: TelemtUserInfo | null + try { + user = await resolveUserBySecret(app.telemt, parsed.data.secretOrLink) + } catch { + return reply.code(502).send({ + error: { code: 'telemt_error', message: 'Не удалось связаться с Telemt' }, + }) + } + + if (!user?.username) { + return reply.code(401).send({ + error: { code: 'invalid_secret', message: 'Пользователь с таким секретом не найден' }, + }) + } + + if (isChildUser(app, user.username)) { + return reply.code(403).send({ + error: { + code: 'pod_child_forbidden', + message: 'Подчинённый пользователь не может входить в /pod', + }, + }) + } + + const settings = getPodSettingsRow(app, user.username) + if (!settings.canCreateChildren) { + return reply.code(403).send({ + error: { + code: 'pod_create_disabled', + message: 'Создание подчинённых для этого аккаунта не разрешено', + }, + }) + } + + const accessToken = await reply.jwtSign( + { + sub: `pod:${user.username}`, + username: user.username, + role: 'pod_parent', + }, + { expiresIn: `${app.config.jwtTtlHours}h` }, + ) + + const childrenCount = countChildren(app, user.username) + return { + accessToken, + username: user.username, + canCreateChildren: settings.canCreateChildren, + maxChildren: settings.maxChildren, + childrenCount, + remaining: remainingSlots( + settings.canCreateChildren, + settings.maxChildren, + childrenCount, + ), + } + }, + ) + + app.get( + '/api/pod/me', + { preHandler: requirePodAuth }, + async (request, reply) => { + const parent = getPodParent(request) + if (isChildUser(app, parent.username)) { + return reply.code(403).send({ + error: { + code: 'pod_child_forbidden', + message: 'Подчинённый пользователь не может использовать /pod', + }, + }) + } + + const settings = getPodSettingsRow(app, parent.username) + const childrenCount = countChildren(app, parent.username) + return { + username: parent.username, + canCreateChildren: settings.canCreateChildren, + maxChildren: settings.maxChildren, + childrenCount, + remaining: remainingSlots( + settings.canCreateChildren, + settings.maxChildren, + childrenCount, + ), + } + }, + ) + + app.get( + '/api/pod/children', + { preHandler: requirePodAuth }, + async (request) => { + const parent = getPodParent(request) + const links = app.db + .select() + .from(userHierarchy) + .where(eq(userHierarchy.parentUsername, parent.username)) + .all() + + const telemtUsers = await fetchTelemtUsers(app.telemt) + const byName = new Map(telemtUsers.map((u) => [u.username, u])) + + const children = links.map((row) => { + const user = byName.get(row.childUsername) ?? null + return { + username: row.childUsername, + parentUsername: row.parentUsername, + createdAt: row.createdAt, + user, + } + }) + + return { children } + }, + ) + + app.post( + '/api/pod/children', + { preHandler: requirePodAuth }, + async (request, reply) => { + const parent = getPodParent(request) + + if (isChildUser(app, parent.username)) { + return reply.code(403).send({ + error: { + code: 'pod_child_forbidden', + message: 'Подчинённый не может создавать пользователей', + }, + }) + } + + const settings = getPodSettingsRow(app, parent.username) + if (!settings.canCreateChildren) { + return reply.code(403).send({ + error: { + code: 'pod_create_disabled', + message: 'Создание подчинённых не разрешено', + }, + }) + } + + const childrenCount = countChildren(app, parent.username) + if (childrenCount >= settings.maxChildren) { + return reply.code(403).send({ + error: { + code: 'pod_limit_reached', + message: `Достигнут лимит подчинённых (${settings.maxChildren})`, + }, + }) + } + + const parsed = createChildBodySchema.safeParse(request.body) + if (!parsed.success) { + return reply.code(400).send({ + error: { + code: 'validation_error', + message: 'Некорректные данные: username (и опционально secret 32 hex)', + }, + }) + } + + const childUsername = parsed.data.username + if (isChildUser(app, childUsername) || childUsername === parent.username) { + return reply.code(409).send({ + error: { + code: 'conflict', + message: 'Пользователь уже существует в иерархии или совпадает с родителем', + }, + }) + } + + const { status, envelope } = await app.telemt.request({ + method: 'POST', + path: '/v1/users', + body: { + username: childUsername, + ...(parsed.data.secret ? { secret: parsed.data.secret } : {}), + }, + }) + + if (status >= 400 || !envelope.ok) { + return reply.code(status >= 400 ? status : 502).send({ + error: envelope.error ?? { + code: 'telemt_error', + message: 'Не удалось создать пользователя в Telemt', + }, + }) + } + + const createdAt = nowIso() + try { + app.db + .insert(userHierarchy) + .values({ + childUsername, + parentUsername: parent.username, + createdAt, + }) + .run() + } catch { + return reply.code(409).send({ + error: { + code: 'hierarchy_conflict', + message: 'Пользователь создан в Telemt, но уже есть в иерархии', + }, + data: envelope.data, + }) + } + + return { + ok: true, + data: envelope.data, + hierarchy: { + childUsername, + parentUsername: parent.username, + createdAt, + }, + } + }, + ) + + app.post('/api/pod/logout', { preHandler: requirePodAuth }, async () => { + return { ok: true } + }) + + app.get( + '/api/user-hierarchy', + { preHandler: requireAuth }, + async () => { + const links = app.db.select().from(userHierarchy).all() + const settingsRows = app.db.select().from(userPodSettings).all() + + const childrenByParent: Record = {} + const parentByChild: Record = {} + for (const row of links) { + parentByChild[row.childUsername] = row.parentUsername + const list = childrenByParent[row.parentUsername] ?? [] + list.push(row.childUsername) + childrenByParent[row.parentUsername] = list + } + + const settings: Record< + string, + { canCreateChildren: boolean; maxChildren: number; childrenCount: number } + > = {} + for (const row of settingsRows) { + settings[row.username] = { + canCreateChildren: Boolean(row.canCreateChildren), + maxChildren: row.maxChildren, + childrenCount: childrenByParent[row.username]?.length ?? 0, + } + } + + // Include parents that have children but no settings row yet + for (const parent of Object.keys(childrenByParent)) { + if (!settings[parent]) { + settings[parent] = { + canCreateChildren: false, + maxChildren: 0, + childrenCount: childrenByParent[parent]?.length ?? 0, + } + } + } + + return { childrenByParent, parentByChild, settings } + }, + ) + + app.get( + '/api/user-pod-settings', + { preHandler: requireAuth }, + async () => { + const rows = app.db.select().from(userPodSettings).all() + return { + settings: rows.map((row) => ({ + username: row.username, + canCreateChildren: Boolean(row.canCreateChildren), + maxChildren: row.maxChildren, + childrenCount: countChildren(app, row.username), + updatedAt: row.updatedAt, + })), + } + }, + ) + + app.put( + '/api/user-pod-settings/:username', + { preHandler: requireAuth }, + async (request, reply) => { + getOperator(request) + const username = (request.params as { username: string }).username + if (!username) { + return reply.code(400).send({ + error: { code: 'validation_error', message: 'username обязателен' }, + }) + } + + if (isChildUser(app, username)) { + return reply.code(400).send({ + error: { + code: 'invalid_target', + message: 'Настройки /pod доступны только главным пользователям', + }, + }) + } + + const parsed = podSettingsBodySchema.safeParse(request.body) + if (!parsed.success) { + return reply.code(400).send({ + error: { + code: 'validation_error', + message: 'Ожидаются canCreateChildren (boolean) и maxChildren (integer ≥ 0)', + }, + }) + } + + const updatedAt = nowIso() + const existing = app.db + .select() + .from(userPodSettings) + .where(eq(userPodSettings.username, username)) + .get() + + if (existing) { + app.db + .update(userPodSettings) + .set({ + canCreateChildren: parsed.data.canCreateChildren, + maxChildren: parsed.data.maxChildren, + updatedAt, + }) + .where(eq(userPodSettings.username, username)) + .run() + } else { + app.db + .insert(userPodSettings) + .values({ + username, + canCreateChildren: parsed.data.canCreateChildren, + maxChildren: parsed.data.maxChildren, + updatedAt, + }) + .run() + } + + const childrenCount = countChildren(app, username) + return { + username, + canCreateChildren: parsed.data.canCreateChildren, + maxChildren: parsed.data.maxChildren, + childrenCount, + remaining: remainingSlots( + parsed.data.canCreateChildren, + parsed.data.maxChildren, + childrenCount, + ), + updatedAt, + } + }, + ) +} + +/** Best-effort cleanup when a Telemt user is deleted via admin proxy. */ +export function cleanupUserPanelData(app: FastifyInstance, username: string): void { + app.db.delete(userHierarchy).where(eq(userHierarchy.childUsername, username)).run() + app.db + .delete(userHierarchy) + .where(eq(userHierarchy.parentUsername, username)) + .run() + app.db.delete(userPodSettings).where(eq(userPodSettings.username, username)).run() +} diff --git a/apps/api/src/routes/telemt.ts b/apps/api/src/routes/telemt.ts index 7c315e2..feab71c 100644 --- a/apps/api/src/routes/telemt.ts +++ b/apps/api/src/routes/telemt.ts @@ -5,6 +5,23 @@ import { telemtProxyRequestSchema } from '@telemt/shared' import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db' import { requireAuth, getOperator } from '../plugins/auth-guards.js' import { sha256, randomBytes } from './auth.js' +import { cleanupUserPanelData } from './pod.js' + +function maybeCleanupDeletedUser( + app: FastifyInstance, + method: string, + suffix: string, + status: number, +): void { + if (method !== 'DELETE' || status >= 400) return + const match = /^users\/([^/?]+)$/.exec(suffix) + if (!match?.[1]) return + try { + cleanupUserPanelData(app, decodeURIComponent(match[1])) + } catch { + // best-effort + } +} export async function telemtRoutes(app: FastifyInstance) { app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => { @@ -23,6 +40,7 @@ export async function telemtRoutes(app: FastifyInstance) { body: method === 'GET' || method === 'DELETE' ? undefined : request.body, ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined, }) + maybeCleanupDeletedUser(app, method, suffix, status) return reply.code(status).send(envelope) } catch (err) { const message = err instanceof Error ? err.message : 'Telemt unreachable' diff --git a/apps/api/src/services/pod-auth.ts b/apps/api/src/services/pod-auth.ts new file mode 100644 index 0000000..4b9d892 --- /dev/null +++ b/apps/api/src/services/pod-auth.ts @@ -0,0 +1,122 @@ +import type { TelemtClient, TelemtEnvelope } from './telemt-client.js' + +export interface TelemtUserLinks { + classic?: string[] + secure?: string[] + tls?: string[] + tls_domains?: Array<{ domain?: string; link?: string }> +} + +export interface TelemtUserInfo { + username: string + enabled?: boolean + links?: TelemtUserLinks + [key: string]: unknown +} + +/** Extract Telemt proxy secret from raw hex or tg:// / t.me proxy URL. */ +export function extractPodSecret(input: string): string | null { + const trimmed = input.trim() + if (!trimmed) return null + + const looksLikeUrl = + /^(tg:\/\/|https?:\/\/)/i.test(trimmed) || + /t\.me\/(proxy|socks)/i.test(trimmed) + + if (looksLikeUrl) { + try { + const normalized = trimmed.replace(/^tg:\/\//i, 'https://tg/') + const url = new URL(normalized) + const secret = url.searchParams.get('secret') + if (secret && secret.trim()) return secret.trim() + } catch { + const match = /[?&]secret=([^&\s#]+)/i.exec(trimmed) + if (match?.[1]) { + try { + return decodeURIComponent(match[1]).trim() + } catch { + return match[1].trim() + } + } + } + return null + } + + return trimmed +} + +function collectLinkStrings(links?: TelemtUserLinks): string[] { + if (!links) return [] + const out: string[] = [] + for (const list of [links.classic, links.secure, links.tls]) { + if (Array.isArray(list)) out.push(...list) + } + for (const row of links.tls_domains ?? []) { + if (row?.link) out.push(row.link) + } + return out +} + +export function userLinksContainSecret( + user: TelemtUserInfo, + secret: string, +): boolean { + if (!secret) return false + return collectLinkStrings(user.links).some((link) => link.includes(secret)) +} + +function normalizeUsersList(envelope: TelemtEnvelope): TelemtUserInfo[] { + const data = envelope.data + if (Array.isArray(data)) return data as TelemtUserInfo[] + if (data && typeof data === 'object' && Array.isArray((data as { users?: unknown }).users)) { + return (data as { users: TelemtUserInfo[] }).users + } + return [] +} + +export async function resolveUserBySecret( + telemt: TelemtClient, + secretOrLink: string, +): Promise { + const secret = extractPodSecret(secretOrLink) + if (!secret) return null + + const { status, envelope } = await telemt.request({ + method: 'GET', + path: '/v1/users', + }) + if (status >= 400 || !envelope.ok) return null + + const users = normalizeUsersList(envelope) + return users.find((u) => userLinksContainSecret(u, secret)) ?? null +} + +export async function fetchTelemtUsers( + telemt: TelemtClient, +): Promise { + const { status, envelope } = await telemt.request({ + method: 'GET', + path: '/v1/users', + }) + if (status >= 400 || !envelope.ok) return [] + return normalizeUsersList(envelope) +} + +export async function fetchTelemtUser( + telemt: TelemtClient, + username: string, +): Promise { + const { status, envelope } = await telemt.request({ + method: 'GET', + path: `/v1/users/${encodeURIComponent(username)}`, + }) + if (status >= 400 || !envelope.ok) return null + const data = envelope.data + if (data && typeof data === 'object' && 'username' in (data as object)) { + return data as TelemtUserInfo + } + if (data && typeof data === 'object' && 'user' in (data as object)) { + return (data as { user: TelemtUserInfo }).user + } + return null +} diff --git a/apps/web/src/components/pod/pod-auth-form.tsx b/apps/web/src/components/pod/pod-auth-form.tsx new file mode 100644 index 0000000..b4dd322 --- /dev/null +++ b/apps/web/src/components/pod/pod-auth-form.tsx @@ -0,0 +1,98 @@ +'use no memo' + +import { useState, type FormEvent } from 'react' +import { toast } from 'sonner' + +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { + Frame, + FrameDescription, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Button } from '@telemt/ui/components/button' +import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field' +import { Input } from '@telemt/ui/components/input' +import { ApiError } from '@/lib/api-client' +import { podApi, type PodSessionResponse } from '@/lib/pod-api' +import { setPodToken } from '@/lib/pod-auth' + +/** + * Public /pod login — auth-13 DNA, secret or tg://proxy link. + * @see https://reui.io/preview/base/auth-13 + * @see https://reui.io/docs/blocks + */ +export function PodAuthForm({ onSuccess }: { onSuccess: () => void }) { + const [secretOrLink, setSecretOrLink] = useState('') + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setPending(true) + setError(null) + try { + const res = await podApi('/api/pod/session', { + method: 'POST', + body: JSON.stringify({ secretOrLink }), + }) + setPodToken(res.accessToken) + toast.success(`Вход: ${res.username}`) + onSuccess() + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : 'Ошибка входа' + setError(message) + } finally { + setPending(false) + } + } + + return ( + + +
+ Для пользователей + + Вставьте ссылку Telegram-прокси или секрет аккаунта + +
+
+ + + Ссылка или секрет + setSecretOrLink(e.target.value)} + placeholder="tg://proxy?…&secret=… или hex" + spellCheck={false} + autoComplete="off" + inputMode="text" + required + className="min-h-11" + /> + + + {error ? ( + + Вход недоступен + {error} + + ) : null} + +
+
+ + ) +} diff --git a/apps/web/src/components/pod/pod-children-list.tsx b/apps/web/src/components/pod/pod-children-list.tsx new file mode 100644 index 0000000..286c702 --- /dev/null +++ b/apps/web/src/components/pod/pod-children-list.tsx @@ -0,0 +1,74 @@ +'use no memo' + +import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' + +import { Badge } from '@/components/reui/badge' +import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid' +import { EnabledBadge } from '@/components/users/users-columns' +import type { PodChildRow } from '@/lib/pod-api' + +function createPodChildrenColumns(): ColumnDef[] { + return [ + { + accessorKey: 'username', + header: 'Имя', + cell: ({ row }) => ( + {row.original.username} + ), + }, + { + id: 'enabled', + header: 'Статус', + cell: ({ row }) => ( + + ), + size: 100, + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: 'Создан', + cell: ({ row }) => ( + + {row.original.createdAt + ? new Date(row.original.createdAt).toLocaleString('ru-RU') + : '—'} + + ), + }, + ] +} + +export function PodChildrenList({ + children, + isLoading, +}: { + children: PodChildRow[] + isLoading?: boolean +}) { + const columns = useMemo(() => createPodChildrenColumns(), []) + + if (!isLoading && children.length === 0) { + return ( +
+ Пока пусто +

Создайте первого подчинённого пользователя.

+
+ ) + } + + return ( + row.username} + pageSize={10} + dense + plain + emptyTitle="Нет подчинённых" + /> + ) +} diff --git a/apps/web/src/components/pod/pod-create-child.tsx b/apps/web/src/components/pod/pod-create-child.tsx new file mode 100644 index 0000000..11de9f4 --- /dev/null +++ b/apps/web/src/components/pod/pod-create-child.tsx @@ -0,0 +1,261 @@ +'use no memo' + +import { useState, type FormEvent } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { UserShareLinks } from '@/components/users/user-share-links' +import { Button } from '@telemt/ui/components/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@telemt/ui/components/dialog' +import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field' +import { Input } from '@telemt/ui/components/input' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@telemt/ui/components/sheet' +import { ApiError } from '@/lib/api-client' +import { podApi } from '@/lib/pod-api' +import { + collectUserShareLinks, + unwrapData, + type CreateUserResponse, + type UserShareLink, +} from '@/lib/telemt' +import { useIsMobile } from '@/hooks/use-is-mobile' + +/** + * Create subordinate — form-7 DNA; Sheet on mobile, Dialog on desktop. + * @see https://reui.io/preview/base/form-7 + */ +export function PodCreateChild({ + open, + onOpenChange, + canCreate, + remaining, +}: { + open: boolean + onOpenChange: (open: boolean) => void + canCreate: boolean + remaining: number +}) { + const qc = useQueryClient() + const isMobile = useIsMobile() + const [username, setUsername] = useState('') + const [secret, setSecret] = useState('') + const [createdSecret, setCreatedSecret] = useState(null) + const [createdLinks, setCreatedLinks] = useState([]) + const [formError, setFormError] = useState(null) + + const create = useMutation({ + mutationFn: async () => { + const body: { username: string; secret?: string } = { + username: username.trim(), + } + if (secret.trim()) body.secret = secret.trim() + return podApi<{ data?: CreateUserResponse }>('/api/pod/children', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + onSuccess: (res) => { + const payload = + unwrapData(res) ?? + (res?.data && typeof res.data === 'object' + ? (res.data as CreateUserResponse) + : null) + setCreatedSecret(payload?.secret ?? null) + setCreatedLinks(collectUserShareLinks(payload?.user?.links)) + setUsername('') + setSecret('') + setFormError(null) + void qc.invalidateQueries({ queryKey: ['pod'] }) + toast.success('Подчинённый создан') + }, + onError: (err) => { + const message = + err instanceof ApiError ? err.message : 'Не удалось создать' + setFormError(message) + toast.error(message) + }, + }) + + function reset() { + setUsername('') + setSecret('') + setCreatedSecret(null) + setCreatedLinks([]) + setFormError(null) + } + + function handleOpenChange(next: boolean) { + onOpenChange(next) + if (!next) reset() + } + + function handleSubmit(e: FormEvent) { + e.preventDefault() + if (!username.trim() || !canCreate || remaining <= 0) return + create.mutate() + } + + const showCreated = Boolean(createdSecret) || createdLinks.length > 0 + const disabled = !canCreate || remaining <= 0 + + const body = showCreated ? ( +
+ {createdSecret ? ( +
+

Секрет (сохраните сейчас)

+ + {createdSecret} + + +
+ ) : null} + + +
+ ) : ( +
+ {disabled ? ( + + Создание недоступно + + {!canCreate + ? 'Для аккаунта запрещено создавать подчинённых.' + : 'Достигнут лимит подчинённых.'} + + + ) : null} + {formError ? ( + + Ошибка + {formError} + + ) : null} + + + Имя + setUsername(e.target.value)} + pattern="[A-Za-z0-9_.\-]+" + maxLength={64} + required + autoComplete="username" + inputMode="text" + className="min-h-11" + disabled={disabled} + /> + + + Секрет (опц.) + setSecret(e.target.value)} + spellCheck={false} + inputMode="text" + placeholder="оставьте пустым — сгенерирует Telemt" + className="min-h-11" + disabled={disabled} + /> + + + +
+ ) + + if (isMobile) { + return ( + + + + + {showCreated ? 'Пользователь создан' : 'Новый подчинённый'} + + + {showCreated + ? 'Сохраните секрет и ссылки для Telegram.' + : `Осталось слотов: ${remaining}`} + + +
{body}
+ {!showCreated ? ( + + + + ) : null} +
+
+ ) + } + + return ( + + + + + {showCreated ? 'Пользователь создан' : 'Новый подчинённый'} + + + {showCreated + ? 'Сохраните секрет и ссылки для Telegram.' + : `Осталось слотов: ${remaining}`} + + + {body} + {!showCreated ? ( + + + + ) : null} + + + ) +} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index a05ce70..325b46f 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -1,12 +1,14 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import { getCoreRowModel, + getExpandedRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnDef, type ColumnOrderState, type ColumnPinningState, + type ExpandedState, type OnChangeFn, type PaginationState, type RowPinningState, @@ -116,6 +118,10 @@ export interface ResourcePageProps extends SimpleGridPassthrou pinLastColumn?: boolean /** Enable row pinning (data-grid-base-2 DNA). */ enableRowPinning?: boolean + /** Tree rows — Preview: https://reui.io/preview/base/data-grid-expansion-1 */ + getSubRows?: (row: T) => T[] | undefined + /** Expand all expandable rows by default when getSubRows is set. */ + defaultExpanded?: boolean /** FrameDataGrid aliases when used as simple list. */ emptyTitle?: string emptyDescription?: string @@ -302,6 +308,8 @@ function ResourcePageFiltered({ hideHeader = false, pinLastColumn = false, enableRowPinning = false, + getSubRows, + defaultExpanded = true, onRowClick, dense = true, initialSorting, @@ -318,6 +326,9 @@ function ResourcePageFiltered({ const showFilters = filterFields.length > 0 const [sorting, setSorting] = useState(initialSorting ?? []) + const [expanded, setExpanded] = useState( + defaultExpanded && getSubRows ? true : {}, + ) const [rowSelection, setRowSelection] = useState({}) const [rowPinning, setRowPinning] = useState({ top: [], @@ -423,8 +434,10 @@ function ResourcePageFiltered({ data: filteredData, columns, getRowId: (row, index) => getRowId(row, index), + getSubRows, state: { sorting, + expanded, rowSelection, pagination, columnVisibility, @@ -440,6 +453,7 @@ function ResourcePageFiltered({ enableSorting: true, manualSorting: false, onSortingChange: handleSortingChange, + onExpandedChange: setExpanded, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, onColumnVisibilityChange: setColumnVisibility, @@ -448,6 +462,7 @@ function ResourcePageFiltered({ onRowPinningChange: enableRowPinning ? setRowPinning : undefined, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined, getPaginationRowModel: getPaginationRowModel(), }) diff --git a/apps/web/src/components/users/user-detail-sheet.tsx b/apps/web/src/components/users/user-detail-sheet.tsx index 253fe65..b43f06d 100644 --- a/apps/web/src/components/users/user-detail-sheet.tsx +++ b/apps/web/src/components/users/user-detail-sheet.tsx @@ -1,7 +1,15 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { MapPinIcon, NetworkIcon } from 'lucide-react' +import { toast } from 'sonner' import { Badge } from '@/components/reui/badge' +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/components/reui/number-field' import { Timeline, TimelineContent, @@ -22,7 +30,10 @@ import { } from '@telemt/ui/components/sheet' import { ScrollArea } from '@telemt/ui/components/scroll-area' import { Skeleton } from '@telemt/ui/components/skeleton' -import { api } from '@/lib/api-client' +import { Switch } from '@telemt/ui/components/switch' +import { Field, FieldLabel } from '@telemt/ui/components/field' +import { api, ApiError } from '@/lib/api-client' +import type { UserHierarchyResponse } from '@/lib/pod-api' import { UserShareLinks } from '@/components/users/user-share-links' import { asRecord, @@ -45,12 +56,20 @@ interface UserDetailSheetProps { /** User detail card — Frame DNA + ReUI Timeline. Docs: https://reui.io/docs/components/base/timeline */ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailSheetProps) { + const qc = useQueryClient() + const detail = useQuery({ queryKey: ['telemt', 'user', username], queryFn: () => api(`/api/telemt/users/${encodeURIComponent(username!)}`), enabled: open && Boolean(username), }) + const hierarchyQuery = useQuery({ + queryKey: ['user-hierarchy'], + queryFn: () => api('/api/user-hierarchy'), + enabled: open && Boolean(username), + }) + const events = useQuery({ queryKey: ['telemt', 'events', 'user-detail'], queryFn: () => @@ -69,6 +88,38 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee unwrapData(detail.data) ?? (detail.data as UserInfo | undefined) + const parentUsername = username + ? hierarchyQuery.data?.parentByChild[username] + : undefined + const isChild = Boolean(parentUsername) + const podSettings = username + ? hierarchyQuery.data?.settings[username] + : undefined + const childrenCount = + podSettings?.childrenCount ?? + (username ? hierarchyQuery.data?.childrenByParent[username]?.length ?? 0 : 0) + const canCreateChildren = podSettings?.canCreateChildren ?? false + const maxChildren = podSettings?.maxChildren ?? 0 + + const savePodSettings = useMutation({ + mutationFn: async (opts: { + canCreateChildren: boolean + maxChildren: number + }) => { + await api(`/api/user-pod-settings/${encodeURIComponent(username!)}`, { + method: 'PUT', + body: JSON.stringify(opts), + }) + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['user-hierarchy'] }) + toast.success('Настройки /pod сохранены') + }, + onError: (err) => { + toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить') + }, + }) + const eventPayload = asRecord(unwrapData(events.data) ?? events.data) const eventList: ApiEventRecord[] = Array.isArray(eventPayload.events) ? (eventPayload.events as ApiEventRecord[]) @@ -195,6 +246,78 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee + {!isChild ? ( + <> + +
+
+

Доступ /pod

+ + Master + +
+ + + Может создавать подчинённых + + { + savePodSettings.mutate({ + canCreateChildren: Boolean(checked), + maxChildren, + }) + }} + /> + + + Лимит подчинённых +
+ { + if (value == null) return + savePodSettings.mutate({ + canCreateChildren, + maxChildren: value, + }) + }} + className="w-40" + > + + + + + + + + {childrenCount} / {maxChildren} + +
+
+
+ + ) : ( + <> + +
+
+ + Подчинённый + + + родитель: {parentUsername} + +
+
+ + )} + {shareLinks.length > 0 ? ( <> diff --git a/apps/web/src/components/users/users-columns.tsx b/apps/web/src/components/users/users-columns.tsx index b748c4d..3caaa9d 100644 --- a/apps/web/src/components/users/users-columns.tsx +++ b/apps/web/src/components/users/users-columns.tsx @@ -14,10 +14,14 @@ import { toast } from 'sonner' import { Badge } from '@/components/reui/badge' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' -import { DataGridTableRowPin } from '@/components/reui/data-grid/data-grid-table' +import { + DataGridTableRowExpand, + DataGridTableRowPin, +} from '@/components/reui/data-grid/data-grid-table' import { progressToneClass, statusDotClass } from '@/components/reui-kit/grid-tokens' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { cn } from '@telemt/ui/lib/utils' +import type { UserTreeRow } from '@/lib/user-hierarchy' import { AlertDialog, AlertDialogAction, @@ -49,6 +53,14 @@ import { formatNumber, type UserInfo, } from '@/lib/telemt' +import { Switch } from '@telemt/ui/components/switch' +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from '@/components/reui/number-field' export function EnabledBadge({ enabled }: { enabled: boolean }) { return ( @@ -155,13 +167,15 @@ function QuotaCell({ user }: { user: UserInfo }) { ) } -const UserCell = memo(function UserCell({ row }: { row: Row }) { +const UserCell = memo(function UserCell({ row }: { row: Row }) { const o = row.original const enabled = o.enabled !== false const initials = o.username.slice(0, 2).toUpperCase() + const isChild = o.hierarchyRole === 'child' return ( -
+
+
{initials} @@ -175,23 +189,104 @@ const UserCell = memo(function UserCell({ row }: { row: Row }) { />
-
{o.username}
+
+ + {o.username} + + + {isChild ? 'Подчинённый' : 'Master'} + +
- {formatBytes(o.total_octets)} · {formatNumber(o.active_unique_ips)} IP + {isChild && o.parentUsername + ? `← ${o.parentUsername}` + : `${formatBytes(o.total_octets)} · ${formatNumber(o.active_unique_ips)} IP`}
) }) +function PodAccessCell({ + row, + onSaveSettings, +}: { + row: Row + onSaveSettings?: (opts: { + username: string + canCreateChildren: boolean + maxChildren: number + }) => void +}) { + const o = row.original + if (o.hierarchyRole === 'child') { + return + } + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > +
+ { + onSaveSettings?.({ + username: o.username, + canCreateChildren: Boolean(checked), + maxChildren: o.maxChildren, + }) + }} + aria-label="Может создавать подчинённых" + /> + + /pod + +
+
+ { + if (value == null) return + onSaveSettings?.({ + username: o.username, + canCreateChildren: o.canCreateChildren, + maxChildren: value, + }) + }} + className="w-[7.5rem]" + size="sm" + > + + + + + + + + {o.childrenCount}/{o.maxChildren} + +
+
+ ) +} + export function ActionsCell({ row, onOpen, onDelete, }: { - row: Row - onOpen: (user: UserInfo) => void - onDelete: (user: UserInfo) => void + row: Row + onOpen: (user: UserTreeRow) => void + onDelete: (user: UserTreeRow) => void }) { const { copyToClipboard } = useCopyToClipboard() const [deleteOpen, setDeleteOpen] = useState(false) @@ -286,9 +381,14 @@ export function ActionsCell({ } export function createUsersColumns(opts: { - onOpen: (user: UserInfo) => void - onDelete: (user: UserInfo) => void -}): ColumnDef[] { + onOpen: (user: UserTreeRow) => void + onDelete: (user: UserTreeRow) => void + onSavePodSettings?: (opts: { + username: string + canCreateChildren: boolean + maxChildren: number + }) => void +}): ColumnDef[] { return [ { id: 'pin', @@ -316,7 +416,7 @@ export function createUsersColumns(opts: { enableSorting: true, enableHiding: false, enableResizing: true, - minSize: 200, + minSize: 220, meta: { autoSize: true, skeleton: ( @@ -330,6 +430,30 @@ export function createUsersColumns(opts: { ), }, }, + { + id: 'pod_access', + accessorFn: (row) => + row.hierarchyRole === 'master' + ? Number(row.canCreateChildren) * 1000 + row.maxChildren + : -1, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 168, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, { id: 'enabled', accessorFn: (row) => (row.enabled === false ? 'off' : 'on'), @@ -446,7 +570,6 @@ export function createUsersColumns(opts: { accessorFn: (row) => { const quota = row.data_quota_bytes if (typeof quota === 'number' && quota > 0) return quota - // «Без квоты» в UI показывает usage — сортируем по тому же смыслу return Number(row.total_octets ?? 0) }, header: ({ column }) => ( diff --git a/apps/web/src/components/users/users-grid-view.tsx b/apps/web/src/components/users/users-grid-view.tsx index a5656fc..4e72a60 100644 --- a/apps/web/src/components/users/users-grid-view.tsx +++ b/apps/web/src/components/users/users-grid-view.tsx @@ -1,4 +1,4 @@ -'use no memo' +'use no memo' import { useCallback, useMemo, useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -42,6 +42,8 @@ import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field' import { Input } from '@telemt/ui/components/input' import { TooltipProvider } from '@telemt/ui/components/tooltip' import { api, ApiError } from '@/lib/api-client' +import type { UserHierarchyResponse } from '@/lib/pod-api' +import { buildUserTree, type UserTreeRow } from '@/lib/user-hierarchy' import { collectUserShareLinks, normalizeUsers, @@ -56,10 +58,9 @@ function createDefaultUserFilters(): Filter[] { } /** - * Users grid — ResourcePage + data-grid-base-2 advanced DNA. + * Users grid — ResourcePage + tree hierarchy + /pod quotas. * Preview: https://reui.io/preview/base/data-grid-filtering-2 - * Advanced: https://reui.io/preview/base/data-grid-base-2 - * Docs: https://reui.io/blocks · https://reui.io/docs/components/base/data-grid + * Expansion: https://reui.io/preview/base/data-grid-expansion-1 */ export function UsersGridView() { const qc = useQueryClient() @@ -86,14 +87,25 @@ export function UsersGridView() { refetchInterval: 10_000, }) - const rows = useMemo( + const hierarchyQuery = useQuery({ + queryKey: ['user-hierarchy'], + queryFn: () => api('/api/user-hierarchy'), + refetchInterval: 10_000, + }) + + const flatUsers = useMemo( () => normalizeUsers(usersQuery.data), [usersQuery.data], ) + const rows = useMemo( + () => buildUserTree(flatUsers, hierarchyQuery.data), + [flatUsers, hierarchyQuery.data], + ) + const filterFields = useUsersFilterFields() - const getFilterFieldValue = useCallback((item: UserInfo, field: string) => { + const getFilterFieldValue = useCallback((item: UserTreeRow, field: string) => { if (field === 'enabled') return item.enabled === false ? 'off' : 'on' if (field === 'ip') { return [ @@ -104,19 +116,20 @@ export function UsersGridView() { return (item as unknown as Record)[field] }, []) - const handleOpen = useCallback((user: UserInfo) => { + const handleOpen = useCallback((user: UserTreeRow) => { setSelectedUsername(user.username) setSheetOpen(true) }, []) const removeUser = useMutation({ - mutationFn: async (user: UserInfo) => { + mutationFn: async (user: UserTreeRow) => { await api(`/api/telemt/users/${encodeURIComponent(user.username)}`, { method: 'DELETE', }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['telemt', 'users'] }) + void qc.invalidateQueries({ queryKey: ['user-hierarchy'] }) toast.success('Пользователь удалён') }, onError: (err) => { @@ -124,13 +137,37 @@ export function UsersGridView() { }, }) + const savePodSettings = useMutation({ + mutationFn: async (opts: { + username: string + canCreateChildren: boolean + maxChildren: number + }) => { + await api(`/api/user-pod-settings/${encodeURIComponent(opts.username)}`, { + method: 'PUT', + body: JSON.stringify({ + canCreateChildren: opts.canCreateChildren, + maxChildren: opts.maxChildren, + }), + }) + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['user-hierarchy'] }) + toast.success('Настройки /pod сохранены') + }, + onError: (err) => { + toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить') + }, + }) + const columns = useMemo( () => createUsersColumns({ onOpen: handleOpen, onDelete: (user) => removeUser.mutate(user), + onSavePodSettings: (opts) => savePodSettings.mutate(opts), }), - [handleOpen, removeUser], + [handleOpen, removeUser, savePodSettings], ) const create = useMutation({ @@ -170,6 +207,7 @@ export function UsersGridView() { setUsername('') setSecret('') void qc.invalidateQueries({ queryKey: ['telemt', 'users'] }) + void qc.invalidateQueries({ queryKey: ['user-hierarchy'] }) toast.success('Пользователь создан') if (!data?.secret && links.length === 0) { setCreateOpen(false) @@ -203,11 +241,19 @@ export function UsersGridView() { }, []) const handleExportCsv = useCallback(() => { + const flat: UserTreeRow[] = [] + const walk = (list: UserTreeRow[]) => { + for (const row of list) { + flat.push(row) + if (row.subRows?.length) walk(row.subRows) + } + } + walk(rows) const lines = [ - 'username,enabled,connections,active_ips,traffic', - ...rows.map( + 'username,role,parent,enabled,connections,active_ips,traffic,can_create,max_children', + ...flat.map( (u) => - `${u.username},${u.enabled !== false},${u.current_connections ?? 0},${u.active_unique_ips ?? 0},${u.total_octets ?? 0}`, + `${u.username},${u.hierarchyRole},${u.parentUsername ?? ''},${u.enabled !== false},${u.current_connections ?? 0},${u.active_unique_ips ?? 0},${u.total_octets ?? 0},${u.canCreateChildren},${u.maxChildren}`, ), ] const blob = new Blob([lines.join('\n')], { @@ -231,7 +277,7 @@ export function UsersGridView() { [], ) - const tabFilter = useCallback((item: UserInfo, tabId: string) => { + const tabFilter = useCallback((item: UserTreeRow, tabId: string) => { if (tabId === 'on') return item.enabled !== false if (tabId === 'off') return item.enabled === false return true @@ -245,11 +291,13 @@ export function UsersGridView() { description={ usersQuery.isLoading ? 'Загрузка…' - : `${rows.length} аккаунтов Telemt` + : `${flatUsers.length} аккаунтов Telemt` } columns={columns} data={rows} getRowId={(row) => row.username} + getSubRows={(row) => row.subRows} + defaultExpanded isLoading={usersQuery.isLoading} isError={usersQuery.isError} error={ @@ -259,7 +307,10 @@ export function UsersGridView() { ? new Error(String(usersQuery.error)) : null } - onRetry={() => void usersQuery.refetch()} + onRetry={() => { + void usersQuery.refetch() + void hierarchyQuery.refetch() + }} filterFields={filterFields} filters={filters} onFiltersChange={setFilters} @@ -352,7 +403,7 @@ export function UsersGridView() { {showCreated ? 'Сохраните секрет и ссылки для подключения в Telegram.' - : 'Секрет можно задать (32 hex) или оставить пустым.'} + : 'Секрет можно задать (32 hex) или оставить пустым. Подчинённых создают через /pod.'} diff --git a/apps/web/src/hooks/use-is-mobile.ts b/apps/web/src/hooks/use-is-mobile.ts new file mode 100644 index 0000000..e2c2a47 --- /dev/null +++ b/apps/web/src/hooks/use-is-mobile.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react' + +const MOBILE_QUERY = '(max-width: 767px)' + +/** True below Tailwind `md` breakpoint. */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false + return window.matchMedia(MOBILE_QUERY).matches + }) + + useEffect(() => { + const mql = window.matchMedia(MOBILE_QUERY) + const onChange = () => setIsMobile(mql.matches) + onChange() + mql.addEventListener('change', onChange) + return () => mql.removeEventListener('change', onChange) + }, []) + + return isMobile +} diff --git a/apps/web/src/lib/pod-api.ts b/apps/web/src/lib/pod-api.ts new file mode 100644 index 0000000..6d6d618 --- /dev/null +++ b/apps/web/src/lib/pod-api.ts @@ -0,0 +1,77 @@ +import { clearPodToken, getPodToken } from '@/lib/pod-auth' +import { ApiError } from '@/lib/api-client' + +/** API client with pod Bearer (`telemt_pod_token`). Does not touch operator JWT. */ +export async function podApi( + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers) + const token = getPodToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const res = await fetch(path, { ...init, headers, credentials: 'include' }) + if (res.status === 401) { + clearPodToken() + if (!window.location.pathname.startsWith('/pod')) { + window.location.assign('/pod') + } + throw new ApiError(401, 'unauthorized', 'Требуется вход /pod') + } + + const text = await res.text() + let data: unknown = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = text + } + } + + if (!res.ok) { + const err = data as { error?: { code?: string; message?: string } } | null + throw new ApiError( + res.status, + err?.error?.code ?? 'error', + err?.error?.message ?? res.statusText, + ) + } + + return data as T +} + +export interface PodMe { + username: string + canCreateChildren: boolean + maxChildren: number + childrenCount: number + remaining: number +} + +export interface PodSessionResponse extends PodMe { + accessToken: string +} + +export interface PodChildRow { + username: string + parentUsername: string + createdAt: string + user: { + username: string + enabled?: boolean + links?: unknown + } | null +} + +export interface UserHierarchyResponse { + childrenByParent: Record + parentByChild: Record + settings: Record< + string, + { canCreateChildren: boolean; maxChildren: number; childrenCount: number } + > +} diff --git a/apps/web/src/lib/pod-auth.ts b/apps/web/src/lib/pod-auth.ts new file mode 100644 index 0000000..afbbbe7 --- /dev/null +++ b/apps/web/src/lib/pod-auth.ts @@ -0,0 +1,17 @@ +const POD_TOKEN_KEY = 'telemt_pod_token' + +export function getPodToken(): string | null { + return localStorage.getItem(POD_TOKEN_KEY) +} + +export function setPodToken(token: string): void { + localStorage.setItem(POD_TOKEN_KEY, token) +} + +export function clearPodToken(): void { + localStorage.removeItem(POD_TOKEN_KEY) +} + +export function isPodLoggedIn(): boolean { + return Boolean(getPodToken()) +} diff --git a/apps/web/src/lib/user-hierarchy.ts b/apps/web/src/lib/user-hierarchy.ts new file mode 100644 index 0000000..5fcb93f --- /dev/null +++ b/apps/web/src/lib/user-hierarchy.ts @@ -0,0 +1,65 @@ +import type { UserInfo } from '@/lib/telemt' +import type { UserHierarchyResponse } from '@/lib/pod-api' + +export interface UserTreeRow extends UserInfo { + hierarchyRole: 'master' | 'child' + parentUsername?: string + canCreateChildren: boolean + maxChildren: number + childrenCount: number + subRows?: UserTreeRow[] +} + +export function buildUserTree( + users: UserInfo[], + hierarchy: UserHierarchyResponse | null | undefined, +): UserTreeRow[] { + const byName = new Map(users.map((u) => [u.username, u])) + const parentByChild = hierarchy?.parentByChild ?? {} + const childrenByParent = hierarchy?.childrenByParent ?? {} + const settings = hierarchy?.settings ?? {} + + const childNames = new Set(Object.keys(parentByChild)) + + function toRow( + user: UserInfo, + role: 'master' | 'child', + parentUsername?: string, + ): UserTreeRow { + const s = settings[user.username] + const childrenNames = childrenByParent[user.username] ?? [] + const childRows = + role === 'master' + ? childrenNames + .map((name) => byName.get(name)) + .filter((u): u is UserInfo => Boolean(u)) + .map((u) => toRow(u, 'child', user.username)) + : undefined + + return { + ...user, + hierarchyRole: role, + parentUsername, + canCreateChildren: s?.canCreateChildren ?? false, + maxChildren: s?.maxChildren ?? 0, + childrenCount: s?.childrenCount ?? childRows?.length ?? 0, + subRows: childRows && childRows.length > 0 ? childRows : undefined, + } + } + + const roots: UserTreeRow[] = [] + for (const user of users) { + if (childNames.has(user.username)) continue + roots.push(toRow(user, 'master')) + } + + // Orphans: children whose parent is missing from Telemt list + for (const [child, parent] of Object.entries(parentByChild)) { + if (!byName.has(child)) continue + if (byName.has(parent)) continue + if (roots.some((r) => r.username === child)) continue + roots.push(toRow(byName.get(child)!, 'child', parent)) + } + + return roots +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0385b85..d9e69cf 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as ClientsRouteImport } from './routes/clients' import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as ErrorsRouteImport } from './routes/errors' import { Route as LoginRouteImport } from './routes/login' +import { Route as PodRouteImport } from './routes/pod' import { Route as RuntimeRouteImport } from './routes/runtime' import { Route as SecurityRouteImport } from './routes/security' import { Route as ServersRouteImport } from './routes/servers' @@ -45,6 +46,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const PodRoute = PodRouteImport.update({ + id: '/pod', + path: '/pod', + getParentRoute: () => rootRouteImport, +} as any) const RuntimeRoute = RuntimeRouteImport.update({ id: '/runtime', path: '/runtime', @@ -77,6 +83,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof DashboardRoute '/errors': typeof ErrorsRoute '/login': typeof LoginRoute + '/pod': typeof PodRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute '/servers': typeof ServersRoute @@ -89,6 +96,7 @@ export interface FileRoutesByTo { '/dashboard': typeof DashboardRoute '/errors': typeof ErrorsRoute '/login': typeof LoginRoute + '/pod': typeof PodRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute '/servers': typeof ServersRoute @@ -102,6 +110,7 @@ export interface FileRoutesById { '/dashboard': typeof DashboardRoute '/errors': typeof ErrorsRoute '/login': typeof LoginRoute + '/pod': typeof PodRoute '/runtime': typeof RuntimeRoute '/security': typeof SecurityRoute '/servers': typeof ServersRoute @@ -116,6 +125,7 @@ export interface FileRouteTypes { | '/dashboard' | '/errors' | '/login' + | '/pod' | '/runtime' | '/security' | '/servers' @@ -128,6 +138,7 @@ export interface FileRouteTypes { | '/dashboard' | '/errors' | '/login' + | '/pod' | '/runtime' | '/security' | '/servers' @@ -140,6 +151,7 @@ export interface FileRouteTypes { | '/dashboard' | '/errors' | '/login' + | '/pod' | '/runtime' | '/security' | '/servers' @@ -153,6 +165,7 @@ export interface RootRouteChildren { DashboardRoute: typeof DashboardRoute ErrorsRoute: typeof ErrorsRoute LoginRoute: typeof LoginRoute + PodRoute: typeof PodRoute RuntimeRoute: typeof RuntimeRoute SecurityRoute: typeof SecurityRoute ServersRoute: typeof ServersRoute @@ -197,6 +210,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/pod': { + id: '/pod' + path: '/pod' + fullPath: '/pod' + preLoaderRoute: typeof PodRouteImport + parentRoute: typeof rootRouteImport + } '/runtime': { id: '/runtime' path: '/runtime' @@ -241,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = { DashboardRoute: DashboardRoute, ErrorsRoute: ErrorsRoute, LoginRoute: LoginRoute, + PodRoute: PodRoute, RuntimeRoute: RuntimeRoute, SecurityRoute: SecurityRoute, ServersRoute: ServersRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d90f34f..f824c6b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,9 +6,13 @@ interface RouterContext { queryClient: import('@tanstack/react-query').QueryClient } +function isPublicPath(pathname: string): boolean { + return pathname === '/login' || pathname === '/pod' || pathname.startsWith('/pod/') +} + export const Route = createRootRouteWithContext()({ beforeLoad: ({ location }) => { - if (location.pathname === '/login') return + if (isPublicPath(location.pathname)) return if (!isLoggedIn()) { throw redirect({ to: '/login' }) } @@ -18,7 +22,7 @@ export const Route = createRootRouteWithContext()({ function RootComponent() { const pathname = useRouterState({ select: (s) => s.location.pathname }) - if (pathname === '/login') { + if (isPublicPath(pathname)) { return } diff --git a/apps/web/src/routes/pod.tsx b/apps/web/src/routes/pod.tsx new file mode 100644 index 0000000..1e11981 --- /dev/null +++ b/apps/web/src/routes/pod.tsx @@ -0,0 +1,198 @@ +'use no memo' + +import { useCallback, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { LogOutIcon, UserPlusIcon } from 'lucide-react' + +import { PodAuthForm } from '@/components/pod/pod-auth-form' +import { PodChildrenList } from '@/components/pod/pod-children-list' +import { PodCreateChild } from '@/components/pod/pod-create-child' +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { + Frame, + FrameDescription, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Button } from '@telemt/ui/components/button' +import { clearPodToken, isPodLoggedIn } from '@/lib/pod-auth' +import { podApi, type PodChildRow, type PodMe } from '@/lib/pod-api' + +/** + * Public user portal — create subordinates within admin quota. + * @see https://reui.io/preview/base/auth-13 + * @see https://reui.io/preview/base/form-7 + */ +export const Route = createFileRoute('/pod')({ + component: PodPage, +}) + +function PodPage() { + const qc = useQueryClient() + const [sessionTick, setSessionTick] = useState(0) + const loggedIn = isPodLoggedIn() + + const meQuery = useQuery({ + queryKey: ['pod', 'me', sessionTick], + queryFn: () => podApi('/api/pod/me'), + enabled: loggedIn, + retry: false, + }) + + const childrenQuery = useQuery({ + queryKey: ['pod', 'children', sessionTick], + queryFn: async () => { + const res = await podApi<{ children: PodChildRow[] }>('/api/pod/children') + return res.children + }, + enabled: loggedIn && meQuery.isSuccess, + refetchInterval: 15_000, + }) + + const [createOpen, setCreateOpen] = useState(false) + + const handleAuthed = useCallback(() => { + setSessionTick((n) => n + 1) + void qc.invalidateQueries({ queryKey: ['pod'] }) + }, [qc]) + + const handleLogout = useCallback(() => { + clearPodToken() + void qc.removeQueries({ queryKey: ['pod'] }) + setSessionTick((n) => n + 1) + }, [qc]) + + if (!loggedIn) { + return ( +
+
+ +
+
+ ) + } + + if (meQuery.isError) { + return ( +
+ + Сессия недействительна + + {meQuery.error instanceof Error + ? meQuery.error.message + : 'Войдите снова'} + + + +
+ ) + } + + const me = meQuery.data + const remaining = me?.remaining ?? 0 + const canCreate = Boolean(me?.canCreateChildren) && remaining > 0 + const children = childrenQuery.data ?? [] + + return ( +
+
+
+

Для пользователей

+

+ {meQuery.isLoading ? '…' : me?.username} +

+
+ +
+ +
+ + + Квота + + Создано{' '} + + {me?.childrenCount ?? 0} + {' '} + из{' '} + + {me?.maxChildren ?? 0} + + {remaining > 0 ? ( + <> + {' '} + · осталось{' '} + {remaining} + + ) : null} + + {!me?.canCreateChildren ? ( + + Создание выключено + + Администратор не разрешил создание подчинённых для этого + аккаунта. + + + ) : remaining === 0 ? ( + + Лимит достигнут + + Удалите подчинённого или попросите увеличить лимит. + + + ) : null} + + + +
+ +
+ + +
+ + {canCreate ? ( +
+ +
+ ) : null} + + +
+ ) +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9c2cc4b..868f230 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -97,8 +97,22 @@ export function migrateSchema(sqlite: Sqlite): void { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS user_hierarchy ( + child_username TEXT PRIMARY KEY NOT NULL, + parent_username TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_pod_settings ( + username TEXT PRIMARY KEY NOT NULL, + can_create_children INTEGER NOT NULL DEFAULT 0, + max_children INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_jobs_agent ON jobs(agent_id, status); CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); + CREATE INDEX IF NOT EXISTS idx_user_hierarchy_parent ON user_hierarchy(parent_username); `) } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c234f00..369edfa 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -67,3 +67,20 @@ export const auditLog = sqliteTable('audit_log', { detailsJson: text('details_json'), createdAt: text('created_at').notNull(), }) + +/** Telemt user hierarchy (panel-only; Telemt /v1 has no parent/child). */ +export const userHierarchy = sqliteTable('user_hierarchy', { + childUsername: text('child_username').primaryKey(), + parentUsername: text('parent_username').notNull(), + createdAt: text('created_at').notNull(), +}) + +/** Pod portal quotas for root (master) Telemt users. */ +export const userPodSettings = sqliteTable('user_pod_settings', { + username: text('username').primaryKey(), + canCreateChildren: integer('can_create_children', { mode: 'boolean' }) + .notNull() + .default(false), + maxChildren: integer('max_children').notNull().default(0), + updatedAt: text('updated_at').notNull(), +})