feat(api, web): integrate pod routes and user hierarchy management
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 2m6s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

- Added pod routes to the API and registered them in the app.
- Implemented user hierarchy management with new database tables for user relationships and pod settings.
- Enhanced user detail and grid components to support pod settings, including the ability to create child users and manage their limits.
- Updated authentication guards to include pod-specific authorization checks.
- Improved user interface for managing pod access and settings in the user detail sheet and grid view.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-04 23:42:41 +07:00
co-authored by Cursor
parent 14911cd067
commit 4dbbf09326
21 changed files with 1881 additions and 33 deletions
@@ -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<string | null>(null)
const [pending, setPending] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setPending(true)
setError(null)
try {
const res = await podApi<PodSessionResponse>('/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 (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-1 text-center">
<FrameTitle className="text-xl">Для пользователей</FrameTitle>
<FrameDescription>
Вставьте ссылку Telegram-прокси или секрет аккаунта
</FrameDescription>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="pod-secret">Ссылка или секрет</FieldLabel>
<Input
id="pod-secret"
value={secretOrLink}
onChange={(e) => setSecretOrLink(e.target.value)}
placeholder="tg://proxy?…&secret=… или hex"
spellCheck={false}
autoComplete="off"
inputMode="text"
required
className="min-h-11"
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertTitle>Вход недоступен</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button
type="submit"
disabled={pending || !secretOrLink.trim()}
className="min-h-11 w-full"
>
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
</FramePanel>
</Frame>
)
}
@@ -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<PodChildRow>[] {
return [
{
accessorKey: 'username',
header: 'Имя',
cell: ({ row }) => (
<span className="font-medium">{row.original.username}</span>
),
},
{
id: 'enabled',
header: 'Статус',
cell: ({ row }) => (
<EnabledBadge enabled={row.original.user?.enabled !== false} />
),
size: 100,
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Создан',
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.createdAt
? new Date(row.original.createdAt).toLocaleString('ru-RU')
: '—'}
</span>
),
},
]
}
export function PodChildrenList({
children,
isLoading,
}: {
children: PodChildRow[]
isLoading?: boolean
}) {
const columns = useMemo(() => createPodChildrenColumns(), [])
if (!isLoading && children.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-center text-sm">
<Badge variant="secondary">Пока пусто</Badge>
<p>Создайте первого подчинённого пользователя.</p>
</div>
)
}
return (
<FrameDataGrid
title="Мои подчинённые"
description={`${children.length} пользователей`}
columns={columns}
data={children}
rowId={(row) => row.username}
pageSize={10}
dense
plain
emptyTitle="Нет подчинённых"
/>
)
}
@@ -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<string | null>(null)
const [createdLinks, setCreatedLinks] = useState<UserShareLink[]>([])
const [formError, setFormError] = useState<string | null>(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<CreateUserResponse>(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 ? (
<div className="flex flex-col gap-4">
{createdSecret ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Секрет (сохраните сейчас)</p>
<code className="bg-muted break-all rounded-md p-3 text-xs">
{createdSecret}
</code>
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
onClick={async () => {
await navigator.clipboard.writeText(createdSecret)
toast.success('Секрет скопирован')
}}
>
Копировать секрет
</Button>
</div>
) : null}
<UserShareLinks links={createdLinks} />
<Button
type="button"
className="min-h-11 w-full"
onClick={() => handleOpenChange(false)}
>
Готово
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{disabled ? (
<Alert variant="warning">
<AlertTitle>Создание недоступно</AlertTitle>
<AlertDescription>
{!canCreate
? 'Для аккаунта запрещено создавать подчинённых.'
: 'Достигнут лимит подчинённых.'}
</AlertDescription>
</Alert>
) : null}
{formError ? (
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="pod-child-username">Имя</FieldLabel>
<Input
id="pod-child-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
pattern="[A-Za-z0-9_.\-]+"
maxLength={64}
required
autoComplete="username"
inputMode="text"
className="min-h-11"
disabled={disabled}
/>
</Field>
<Field>
<FieldLabel htmlFor="pod-child-secret">Секрет (опц.)</FieldLabel>
<Input
id="pod-child-secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
spellCheck={false}
inputMode="text"
placeholder="оставьте пустым — сгенерирует Telemt"
className="min-h-11"
disabled={disabled}
/>
</Field>
</FieldGroup>
<Button
type="submit"
className="min-h-11 w-full"
disabled={disabled || create.isPending || !username.trim()}
>
{create.isPending ? 'Создание…' : 'Создать'}
</Button>
</form>
)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="bottom"
className="flex max-h-[92svh] flex-col gap-0 rounded-t-xl p-0"
>
<SheetHeader className="border-b px-4 py-4 text-left">
<SheetTitle>
{showCreated ? 'Пользователь создан' : 'Новый подчинённый'}
</SheetTitle>
<SheetDescription>
{showCreated
? 'Сохраните секрет и ссылки для Telegram.'
: `Осталось слотов: ${remaining}`}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{body}</div>
{!showCreated ? (
<SheetFooter className="border-t px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
onClick={() => handleOpenChange(false)}
>
Отмена
</Button>
</SheetFooter>
) : null}
</SheetContent>
</Sheet>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{showCreated ? 'Пользователь создан' : 'Новый подчинённый'}
</DialogTitle>
<DialogDescription>
{showCreated
? 'Сохраните секрет и ссылки для Telegram.'
: `Осталось слотов: ${remaining}`}
</DialogDescription>
</DialogHeader>
{body}
{!showCreated ? (
<DialogFooter className="sr-only">
<span />
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -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<T extends object> 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<T extends object>({
hideHeader = false,
pinLastColumn = false,
enableRowPinning = false,
getSubRows,
defaultExpanded = true,
onRowClick,
dense = true,
initialSorting,
@@ -318,6 +326,9 @@ function ResourcePageFiltered<T extends object>({
const showFilters = filterFields.length > 0
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [expanded, setExpanded] = useState<ExpandedState>(
defaultExpanded && getSubRows ? true : {},
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
@@ -423,8 +434,10 @@ function ResourcePageFiltered<T extends object>({
data: filteredData,
columns,
getRowId: (row, index) => getRowId(row, index),
getSubRows,
state: {
sorting,
expanded,
rowSelection,
pagination,
columnVisibility,
@@ -440,6 +453,7 @@ function ResourcePageFiltered<T extends object>({
enableSorting: true,
manualSorting: false,
onSortingChange: handleSortingChange,
onExpandedChange: setExpanded,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
@@ -448,6 +462,7 @@ function ResourcePageFiltered<T extends object>({
onRowPinningChange: enableRowPinning ? setRowPinning : undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined,
getPaginationRowModel: getPaginationRowModel(),
})
@@ -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<UserHierarchyResponse>('/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<UserInfo>(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
</dl>
</section>
{!isChild ? (
<>
<Separator />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">Доступ /pod</h3>
<Badge variant="primary-light" size="sm">
Master
</Badge>
</div>
<Field className="flex flex-row items-center justify-between gap-3">
<FieldLabel htmlFor="pod-can-create">
Может создавать подчинённых
</FieldLabel>
<Switch
id="pod-can-create"
checked={canCreateChildren}
disabled={savePodSettings.isPending}
onCheckedChange={(checked) => {
savePodSettings.mutate({
canCreateChildren: Boolean(checked),
maxChildren,
})
}}
/>
</Field>
<Field>
<FieldLabel>Лимит подчинённых</FieldLabel>
<div className="flex items-center gap-3">
<NumberField
value={maxChildren}
min={0}
max={10_000}
disabled={savePodSettings.isPending}
onValueChange={(value) => {
if (value == null) return
savePodSettings.mutate({
canCreateChildren,
maxChildren: value,
})
}}
className="w-40"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<span className="text-muted-foreground text-xs tabular-nums">
{childrenCount} / {maxChildren}
</span>
</div>
</Field>
</section>
</>
) : (
<>
<Separator />
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">
Подчинённый
</Badge>
<span className="text-muted-foreground text-sm">
родитель: {parentUsername}
</span>
</div>
</section>
</>
)}
{shareLinks.length > 0 ? (
<>
<Separator />
+136 -13
View File
@@ -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<UserInfo> }) {
const UserCell = memo(function UserCell({ row }: { row: Row<UserTreeRow> }) {
const o = row.original
const enabled = o.enabled !== false
const initials = o.username.slice(0, 2).toUpperCase()
const isChild = o.hierarchyRole === 'child'
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<DataGridTableRowExpand row={row} />
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
@@ -175,23 +189,104 @@ const UserCell = memo(function UserCell({ row }: { row: Row<UserInfo> }) {
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">{o.username}</div>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="text-foreground line-clamp-1 font-medium">
{o.username}
</span>
<Badge
variant={isChild ? 'secondary' : 'primary-light'}
size="sm"
>
{isChild ? 'Подчинённый' : 'Master'}
</Badge>
</div>
<div className="text-muted-foreground line-clamp-1 text-xs">
{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`}
</div>
</div>
</div>
)
})
function PodAccessCell({
row,
onSaveSettings,
}: {
row: Row<UserTreeRow>
onSaveSettings?: (opts: {
username: string
canCreateChildren: boolean
maxChildren: number
}) => void
}) {
const o = row.original
if (o.hierarchyRole === 'child') {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<div
className="flex min-w-0 flex-col gap-2 py-1"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Switch
checked={o.canCreateChildren}
onCheckedChange={(checked) => {
onSaveSettings?.({
username: o.username,
canCreateChildren: Boolean(checked),
maxChildren: o.maxChildren,
})
}}
aria-label="Может создавать подчинённых"
/>
<span className="text-muted-foreground text-[11px] leading-none">
/pod
</span>
</div>
<div className="flex items-center gap-2">
<NumberField
value={o.maxChildren}
min={0}
max={10_000}
disabled={!onSaveSettings}
onValueChange={(value) => {
if (value == null) return
onSaveSettings?.({
username: o.username,
canCreateChildren: o.canCreateChildren,
maxChildren: value,
})
}}
className="w-[7.5rem]"
size="sm"
>
<NumberFieldGroup size="sm">
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<span className="text-muted-foreground text-[10px] tabular-nums">
{o.childrenCount}/{o.maxChildren}
</span>
</div>
</div>
)
}
export function ActionsCell({
row,
onOpen,
onDelete,
}: {
row: Row<UserInfo>
onOpen: (user: UserInfo) => void
onDelete: (user: UserInfo) => void
row: Row<UserTreeRow>
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<UserInfo>[] {
onOpen: (user: UserTreeRow) => void
onDelete: (user: UserTreeRow) => void
onSavePodSettings?: (opts: {
username: string
canCreateChildren: boolean
maxChildren: number
}) => void
}): ColumnDef<UserTreeRow>[] {
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 }) => (
<DataGridColumnHeader
title="Доступ /pod"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<PodAccessCell row={row} onSaveSettings={opts.onSavePodSettings} />
),
size: 168,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-8 w-28" />,
},
},
{
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 }) => (
@@ -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<UserHierarchyResponse>('/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<string, unknown>)[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() {
<DialogDescription>
{showCreated
? 'Сохраните секрет и ссылки для подключения в Telegram.'
: 'Секрет можно задать (32 hex) или оставить пустым.'}
: 'Секрет можно задать (32 hex) или оставить пустым. Подчинённых создают через /pod.'}
</DialogDescription>
</DialogHeader>
+21
View File
@@ -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
}
+77
View File
@@ -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<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
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<string, string[]>
parentByChild: Record<string, string>
settings: Record<
string,
{ canCreateChildren: boolean; maxChildren: number; childrenCount: number }
>
}
+17
View File
@@ -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())
}
+65
View File
@@ -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
}
+21
View File
@@ -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,
+6 -2
View File
@@ -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<RouterContext>()({
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<RouterContext>()({
function RootComponent() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
if (pathname === '/login') {
if (isPublicPath(pathname)) {
return <Outlet />
}
+198
View File
@@ -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<PodMe>('/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 (
<div className="bg-background relative flex min-h-svh flex-col items-center justify-center p-6">
<div className="mx-auto w-full max-w-lg">
<PodAuthForm onSuccess={handleAuthed} />
</div>
</div>
)
}
if (meQuery.isError) {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-4 p-6">
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Сессия недействительна</AlertTitle>
<AlertDescription>
{meQuery.error instanceof Error
? meQuery.error.message
: 'Войдите снова'}
</AlertDescription>
</Alert>
<Button type="button" onClick={handleLogout}>
Ко входу
</Button>
</div>
)
}
const me = meQuery.data
const remaining = me?.remaining ?? 0
const canCreate = Boolean(me?.canCreateChildren) && remaining > 0
const children = childrenQuery.data ?? []
return (
<div className="bg-background relative flex min-h-svh flex-col">
<header className="border-border/60 flex items-center justify-between gap-3 border-b px-4 py-4 md:px-6">
<div className="min-w-0">
<p className="text-muted-foreground text-xs">Для пользователей</p>
<h1 className="truncate text-base font-semibold md:text-lg">
{meQuery.isLoading ? '…' : me?.username}
</h1>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLogout}
>
<LogOutIcon aria-hidden="true" />
Выйти
</Button>
</header>
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 py-6 pb-28 md:max-w-3xl md:px-6 md:pb-8">
<Frame>
<FramePanel className="flex flex-col gap-2 p-4 md:p-5">
<FrameTitle className="text-base">Квота</FrameTitle>
<FrameDescription>
Создано{' '}
<span className="text-foreground font-medium tabular-nums">
{me?.childrenCount ?? 0}
</span>{' '}
из{' '}
<span className="text-foreground font-medium tabular-nums">
{me?.maxChildren ?? 0}
</span>
{remaining > 0 ? (
<>
{' '}
· осталось{' '}
<span className="tabular-nums">{remaining}</span>
</>
) : null}
</FrameDescription>
{!me?.canCreateChildren ? (
<Alert variant="warning" className="mt-2">
<AlertTitle>Создание выключено</AlertTitle>
<AlertDescription>
Администратор не разрешил создание подчинённых для этого
аккаунта.
</AlertDescription>
</Alert>
) : remaining === 0 ? (
<Alert variant="warning" className="mt-2">
<AlertTitle>Лимит достигнут</AlertTitle>
<AlertDescription>
Удалите подчинённого или попросите увеличить лимит.
</AlertDescription>
</Alert>
) : null}
</FramePanel>
</Frame>
<div className="hidden md:block">
<Button
type="button"
className="w-full min-h-10 sm:w-auto"
disabled={!canCreate}
onClick={() => setCreateOpen(true)}
>
<UserPlusIcon aria-hidden="true" />
Создать пользователя
</Button>
</div>
<PodChildrenList
children={children}
isLoading={childrenQuery.isLoading}
/>
</main>
{canCreate ? (
<div className="border-border/60 bg-background/95 fixed inset-x-0 bottom-0 z-40 border-t p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur md:hidden">
<Button
type="button"
className="min-h-11 w-full"
onClick={() => setCreateOpen(true)}
>
<UserPlusIcon aria-hidden="true" />
Создать пользователя
</Button>
</div>
) : null}
<PodCreateChild
open={createOpen}
onOpenChange={setCreateOpen}
canCreate={Boolean(me?.canCreateChildren)}
remaining={remaining}
/>
</div>
)
}