feat(api, web): integrate pod routes and user hierarchy management
- 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:
@@ -13,6 +13,7 @@ import type { AppConfig } from './config.js'
|
|||||||
import { TelemtClient } from './services/telemt-client.js'
|
import { TelemtClient } from './services/telemt-client.js'
|
||||||
import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js'
|
import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js'
|
||||||
import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js'
|
import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js'
|
||||||
|
import { podRoutes } from './routes/pod.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -82,6 +83,7 @@ export async function buildApp(opts: {
|
|||||||
await app.register(telemtRoutes)
|
await app.register(telemtRoutes)
|
||||||
await app.register(fleetRoutes)
|
await app.register(fleetRoutes)
|
||||||
await app.register(agentProtocolRoutes)
|
await app.register(agentProtocolRoutes)
|
||||||
|
await app.register(podRoutes)
|
||||||
|
|
||||||
app.get('/install-agent.sh', async (_request, reply) => {
|
app.get('/install-agent.sh', async (_request, reply) => {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface AuthOperator {
|
|||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PodParent {
|
||||||
|
username: string
|
||||||
|
role: 'pod_parent'
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@fastify/jwt' {
|
declare module '@fastify/jwt' {
|
||||||
interface FastifyJWT {
|
interface FastifyJWT {
|
||||||
payload: { sub: string; username: string; role: string }
|
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: 'Требуется вход' } })
|
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
|
const row = request.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(operators)
|
.from(operators)
|
||||||
@@ -42,3 +53,26 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
|||||||
export function getOperator(request: FastifyRequest): AuthOperator {
|
export function getOperator(request: FastifyRequest): AuthOperator {
|
||||||
return (request as FastifyRequest & { operator: AuthOperator }).operator
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, string[]> = {}
|
||||||
|
const parentByChild: Record<string, string> = {}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,23 @@ import { telemtProxyRequestSchema } from '@telemt/shared'
|
|||||||
import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db'
|
import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db'
|
||||||
import { requireAuth, getOperator } from '../plugins/auth-guards.js'
|
import { requireAuth, getOperator } from '../plugins/auth-guards.js'
|
||||||
import { sha256, randomBytes } from './auth.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) {
|
export async function telemtRoutes(app: FastifyInstance) {
|
||||||
app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
|
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,
|
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||||
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
||||||
})
|
})
|
||||||
|
maybeCleanupDeletedUser(app, method, suffix, status)
|
||||||
return reply.code(status).send(envelope)
|
return reply.code(status).send(envelope)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||||
|
|||||||
@@ -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<unknown>): 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<TelemtUserInfo | null> {
|
||||||
|
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<TelemtUserInfo[]> {
|
||||||
|
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<TelemtUserInfo | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnOrderState,
|
type ColumnOrderState,
|
||||||
type ColumnPinningState,
|
type ColumnPinningState,
|
||||||
|
type ExpandedState,
|
||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowPinningState,
|
type RowPinningState,
|
||||||
@@ -116,6 +118,10 @@ export interface ResourcePageProps<T extends object> extends SimpleGridPassthrou
|
|||||||
pinLastColumn?: boolean
|
pinLastColumn?: boolean
|
||||||
/** Enable row pinning (data-grid-base-2 DNA). */
|
/** Enable row pinning (data-grid-base-2 DNA). */
|
||||||
enableRowPinning?: boolean
|
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. */
|
/** FrameDataGrid aliases when used as simple list. */
|
||||||
emptyTitle?: string
|
emptyTitle?: string
|
||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
@@ -302,6 +308,8 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
pinLastColumn = false,
|
pinLastColumn = false,
|
||||||
enableRowPinning = false,
|
enableRowPinning = false,
|
||||||
|
getSubRows,
|
||||||
|
defaultExpanded = true,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
dense = true,
|
dense = true,
|
||||||
initialSorting,
|
initialSorting,
|
||||||
@@ -318,6 +326,9 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
const showFilters = filterFields.length > 0
|
const showFilters = filterFields.length > 0
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>(
|
||||||
|
defaultExpanded && getSubRows ? true : {},
|
||||||
|
)
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [rowPinning, setRowPinning] = useState<RowPinningState>({
|
const [rowPinning, setRowPinning] = useState<RowPinningState>({
|
||||||
top: [],
|
top: [],
|
||||||
@@ -423,8 +434,10 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
getRowId: (row, index) => getRowId(row, index),
|
getRowId: (row, index) => getRowId(row, index),
|
||||||
|
getSubRows,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
|
expanded,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
pagination,
|
pagination,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
@@ -440,6 +453,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
manualSorting: false,
|
manualSorting: false,
|
||||||
onSortingChange: handleSortingChange,
|
onSortingChange: handleSortingChange,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
@@ -448,6 +462,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
onRowPinningChange: enableRowPinning ? setRowPinning : undefined,
|
onRowPinningChange: enableRowPinning ? setRowPinning : undefined,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined,
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
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 { MapPinIcon, NetworkIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} from '@/components/reui/number-field'
|
||||||
import {
|
import {
|
||||||
Timeline,
|
Timeline,
|
||||||
TimelineContent,
|
TimelineContent,
|
||||||
@@ -22,7 +30,10 @@ import {
|
|||||||
} from '@telemt/ui/components/sheet'
|
} from '@telemt/ui/components/sheet'
|
||||||
import { ScrollArea } from '@telemt/ui/components/scroll-area'
|
import { ScrollArea } from '@telemt/ui/components/scroll-area'
|
||||||
import { Skeleton } from '@telemt/ui/components/skeleton'
|
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 { UserShareLinks } from '@/components/users/user-share-links'
|
||||||
import {
|
import {
|
||||||
asRecord,
|
asRecord,
|
||||||
@@ -45,12 +56,20 @@ interface UserDetailSheetProps {
|
|||||||
|
|
||||||
/** User detail card — Frame DNA + ReUI Timeline. Docs: https://reui.io/docs/components/base/timeline */
|
/** User detail card — Frame DNA + ReUI Timeline. Docs: https://reui.io/docs/components/base/timeline */
|
||||||
export function UserDetailSheet({ username, open, onOpenChange }: UserDetailSheetProps) {
|
export function UserDetailSheet({ username, open, onOpenChange }: UserDetailSheetProps) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
queryKey: ['telemt', 'user', username],
|
queryKey: ['telemt', 'user', username],
|
||||||
queryFn: () => api(`/api/telemt/users/${encodeURIComponent(username!)}`),
|
queryFn: () => api(`/api/telemt/users/${encodeURIComponent(username!)}`),
|
||||||
enabled: open && Boolean(username),
|
enabled: open && Boolean(username),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hierarchyQuery = useQuery({
|
||||||
|
queryKey: ['user-hierarchy'],
|
||||||
|
queryFn: () => api<UserHierarchyResponse>('/api/user-hierarchy'),
|
||||||
|
enabled: open && Boolean(username),
|
||||||
|
})
|
||||||
|
|
||||||
const events = useQuery({
|
const events = useQuery({
|
||||||
queryKey: ['telemt', 'events', 'user-detail'],
|
queryKey: ['telemt', 'events', 'user-detail'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -69,6 +88,38 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
|
|||||||
unwrapData<UserInfo>(detail.data) ??
|
unwrapData<UserInfo>(detail.data) ??
|
||||||
(detail.data as UserInfo | undefined)
|
(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 eventPayload = asRecord(unwrapData(events.data) ?? events.data)
|
||||||
const eventList: ApiEventRecord[] = Array.isArray(eventPayload.events)
|
const eventList: ApiEventRecord[] = Array.isArray(eventPayload.events)
|
||||||
? (eventPayload.events as ApiEventRecord[])
|
? (eventPayload.events as ApiEventRecord[])
|
||||||
@@ -195,6 +246,78 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</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 ? (
|
{shareLinks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { progressToneClass, statusDotClass } from '@/components/reui-kit/grid-tokens'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { cn } from '@telemt/ui/lib/utils'
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
import type { UserTreeRow } from '@/lib/user-hierarchy'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -49,6 +53,14 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
type UserInfo,
|
type UserInfo,
|
||||||
} from '@/lib/telemt'
|
} 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 }) {
|
export function EnabledBadge({ enabled }: { enabled: boolean }) {
|
||||||
return (
|
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 o = row.original
|
||||||
const enabled = o.enabled !== false
|
const enabled = o.enabled !== false
|
||||||
const initials = o.username.slice(0, 2).toUpperCase()
|
const initials = o.username.slice(0, 2).toUpperCase()
|
||||||
|
const isChild = o.hierarchyRole === 'child'
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative shrink-0">
|
||||||
<Avatar className="size-8">
|
<Avatar className="size-8">
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
@@ -175,23 +189,104 @@ const UserCell = memo(function UserCell({ row }: { row: Row<UserInfo> }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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">
|
<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>
|
</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({
|
export function ActionsCell({
|
||||||
row,
|
row,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
row: Row<UserInfo>
|
row: Row<UserTreeRow>
|
||||||
onOpen: (user: UserInfo) => void
|
onOpen: (user: UserTreeRow) => void
|
||||||
onDelete: (user: UserInfo) => void
|
onDelete: (user: UserTreeRow) => void
|
||||||
}) {
|
}) {
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
@@ -286,9 +381,14 @@ export function ActionsCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createUsersColumns(opts: {
|
export function createUsersColumns(opts: {
|
||||||
onOpen: (user: UserInfo) => void
|
onOpen: (user: UserTreeRow) => void
|
||||||
onDelete: (user: UserInfo) => void
|
onDelete: (user: UserTreeRow) => void
|
||||||
}): ColumnDef<UserInfo>[] {
|
onSavePodSettings?: (opts: {
|
||||||
|
username: string
|
||||||
|
canCreateChildren: boolean
|
||||||
|
maxChildren: number
|
||||||
|
}) => void
|
||||||
|
}): ColumnDef<UserTreeRow>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'pin',
|
id: 'pin',
|
||||||
@@ -316,7 +416,7 @@ export function createUsersColumns(opts: {
|
|||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableResizing: true,
|
enableResizing: true,
|
||||||
minSize: 200,
|
minSize: 220,
|
||||||
meta: {
|
meta: {
|
||||||
autoSize: true,
|
autoSize: true,
|
||||||
skeleton: (
|
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',
|
id: 'enabled',
|
||||||
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
|
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
|
||||||
@@ -446,7 +570,6 @@ export function createUsersColumns(opts: {
|
|||||||
accessorFn: (row) => {
|
accessorFn: (row) => {
|
||||||
const quota = row.data_quota_bytes
|
const quota = row.data_quota_bytes
|
||||||
if (typeof quota === 'number' && quota > 0) return quota
|
if (typeof quota === 'number' && quota > 0) return quota
|
||||||
// «Без квоты» в UI показывает usage — сортируем по тому же смыслу
|
|
||||||
return Number(row.total_octets ?? 0)
|
return Number(row.total_octets ?? 0)
|
||||||
},
|
},
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use no memo'
|
'use no memo'
|
||||||
|
|
||||||
import { useCallback, useMemo, useState, type FormEvent } from 'react'
|
import { useCallback, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { Input } from '@telemt/ui/components/input'
|
||||||
import { TooltipProvider } from '@telemt/ui/components/tooltip'
|
import { TooltipProvider } from '@telemt/ui/components/tooltip'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
|
import type { UserHierarchyResponse } from '@/lib/pod-api'
|
||||||
|
import { buildUserTree, type UserTreeRow } from '@/lib/user-hierarchy'
|
||||||
import {
|
import {
|
||||||
collectUserShareLinks,
|
collectUserShareLinks,
|
||||||
normalizeUsers,
|
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
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* Advanced: https://reui.io/preview/base/data-grid-base-2
|
* Expansion: https://reui.io/preview/base/data-grid-expansion-1
|
||||||
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/data-grid
|
|
||||||
*/
|
*/
|
||||||
export function UsersGridView() {
|
export function UsersGridView() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -86,14 +87,25 @@ export function UsersGridView() {
|
|||||||
refetchInterval: 10_000,
|
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),
|
() => normalizeUsers(usersQuery.data),
|
||||||
[usersQuery.data],
|
[usersQuery.data],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => buildUserTree(flatUsers, hierarchyQuery.data),
|
||||||
|
[flatUsers, hierarchyQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
const filterFields = useUsersFilterFields()
|
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 === 'enabled') return item.enabled === false ? 'off' : 'on'
|
||||||
if (field === 'ip') {
|
if (field === 'ip') {
|
||||||
return [
|
return [
|
||||||
@@ -104,19 +116,20 @@ export function UsersGridView() {
|
|||||||
return (item as unknown as Record<string, unknown>)[field]
|
return (item as unknown as Record<string, unknown>)[field]
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleOpen = useCallback((user: UserInfo) => {
|
const handleOpen = useCallback((user: UserTreeRow) => {
|
||||||
setSelectedUsername(user.username)
|
setSelectedUsername(user.username)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const removeUser = useMutation({
|
const removeUser = useMutation({
|
||||||
mutationFn: async (user: UserInfo) => {
|
mutationFn: async (user: UserTreeRow) => {
|
||||||
await api(`/api/telemt/users/${encodeURIComponent(user.username)}`, {
|
await api(`/api/telemt/users/${encodeURIComponent(user.username)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
|
void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['user-hierarchy'] })
|
||||||
toast.success('Пользователь удалён')
|
toast.success('Пользователь удалён')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createUsersColumns({
|
createUsersColumns({
|
||||||
onOpen: handleOpen,
|
onOpen: handleOpen,
|
||||||
onDelete: (user) => removeUser.mutate(user),
|
onDelete: (user) => removeUser.mutate(user),
|
||||||
|
onSavePodSettings: (opts) => savePodSettings.mutate(opts),
|
||||||
}),
|
}),
|
||||||
[handleOpen, removeUser],
|
[handleOpen, removeUser, savePodSettings],
|
||||||
)
|
)
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
@@ -170,6 +207,7 @@ export function UsersGridView() {
|
|||||||
setUsername('')
|
setUsername('')
|
||||||
setSecret('')
|
setSecret('')
|
||||||
void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
|
void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['user-hierarchy'] })
|
||||||
toast.success('Пользователь создан')
|
toast.success('Пользователь создан')
|
||||||
if (!data?.secret && links.length === 0) {
|
if (!data?.secret && links.length === 0) {
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
@@ -203,11 +241,19 @@ export function UsersGridView() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleExportCsv = useCallback(() => {
|
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 = [
|
const lines = [
|
||||||
'username,enabled,connections,active_ips,traffic',
|
'username,role,parent,enabled,connections,active_ips,traffic,can_create,max_children',
|
||||||
...rows.map(
|
...flat.map(
|
||||||
(u) =>
|
(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')], {
|
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 === 'on') return item.enabled !== false
|
||||||
if (tabId === 'off') return item.enabled === false
|
if (tabId === 'off') return item.enabled === false
|
||||||
return true
|
return true
|
||||||
@@ -245,11 +291,13 @@ export function UsersGridView() {
|
|||||||
description={
|
description={
|
||||||
usersQuery.isLoading
|
usersQuery.isLoading
|
||||||
? 'Загрузка…'
|
? 'Загрузка…'
|
||||||
: `${rows.length} аккаунтов Telemt`
|
: `${flatUsers.length} аккаунтов Telemt`
|
||||||
}
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
getRowId={(row) => row.username}
|
getRowId={(row) => row.username}
|
||||||
|
getSubRows={(row) => row.subRows}
|
||||||
|
defaultExpanded
|
||||||
isLoading={usersQuery.isLoading}
|
isLoading={usersQuery.isLoading}
|
||||||
isError={usersQuery.isError}
|
isError={usersQuery.isError}
|
||||||
error={
|
error={
|
||||||
@@ -259,7 +307,10 @@ export function UsersGridView() {
|
|||||||
? new Error(String(usersQuery.error))
|
? new Error(String(usersQuery.error))
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onRetry={() => void usersQuery.refetch()}
|
onRetry={() => {
|
||||||
|
void usersQuery.refetch()
|
||||||
|
void hierarchyQuery.refetch()
|
||||||
|
}}
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
@@ -352,7 +403,7 @@ export function UsersGridView() {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{showCreated
|
{showCreated
|
||||||
? 'Сохраните секрет и ссылки для подключения в Telegram.'
|
? 'Сохраните секрет и ссылки для подключения в Telegram.'
|
||||||
: 'Секрет можно задать (32 hex) или оставить пустым.'}
|
: 'Секрет можно задать (32 hex) или оставить пустым. Подчинённых создают через /pod.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
>
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { Route as ClientsRouteImport } from './routes/clients'
|
|||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as ErrorsRouteImport } from './routes/errors'
|
import { Route as ErrorsRouteImport } from './routes/errors'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as PodRouteImport } from './routes/pod'
|
||||||
import { Route as RuntimeRouteImport } from './routes/runtime'
|
import { Route as RuntimeRouteImport } from './routes/runtime'
|
||||||
import { Route as SecurityRouteImport } from './routes/security'
|
import { Route as SecurityRouteImport } from './routes/security'
|
||||||
import { Route as ServersRouteImport } from './routes/servers'
|
import { Route as ServersRouteImport } from './routes/servers'
|
||||||
@@ -45,6 +46,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PodRoute = PodRouteImport.update({
|
||||||
|
id: '/pod',
|
||||||
|
path: '/pod',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const RuntimeRoute = RuntimeRouteImport.update({
|
const RuntimeRoute = RuntimeRouteImport.update({
|
||||||
id: '/runtime',
|
id: '/runtime',
|
||||||
path: '/runtime',
|
path: '/runtime',
|
||||||
@@ -77,6 +83,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/errors': typeof ErrorsRoute
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/pod': typeof PodRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
'/servers': typeof ServersRoute
|
'/servers': typeof ServersRoute
|
||||||
@@ -89,6 +96,7 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/errors': typeof ErrorsRoute
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/pod': typeof PodRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
'/servers': typeof ServersRoute
|
'/servers': typeof ServersRoute
|
||||||
@@ -102,6 +110,7 @@ export interface FileRoutesById {
|
|||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/errors': typeof ErrorsRoute
|
'/errors': typeof ErrorsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/pod': typeof PodRoute
|
||||||
'/runtime': typeof RuntimeRoute
|
'/runtime': typeof RuntimeRoute
|
||||||
'/security': typeof SecurityRoute
|
'/security': typeof SecurityRoute
|
||||||
'/servers': typeof ServersRoute
|
'/servers': typeof ServersRoute
|
||||||
@@ -116,6 +125,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/errors'
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/pod'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
| '/servers'
|
| '/servers'
|
||||||
@@ -128,6 +138,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/errors'
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/pod'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
| '/servers'
|
| '/servers'
|
||||||
@@ -140,6 +151,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/errors'
|
| '/errors'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/pod'
|
||||||
| '/runtime'
|
| '/runtime'
|
||||||
| '/security'
|
| '/security'
|
||||||
| '/servers'
|
| '/servers'
|
||||||
@@ -153,6 +165,7 @@ export interface RootRouteChildren {
|
|||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
ErrorsRoute: typeof ErrorsRoute
|
ErrorsRoute: typeof ErrorsRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
|
PodRoute: typeof PodRoute
|
||||||
RuntimeRoute: typeof RuntimeRoute
|
RuntimeRoute: typeof RuntimeRoute
|
||||||
SecurityRoute: typeof SecurityRoute
|
SecurityRoute: typeof SecurityRoute
|
||||||
ServersRoute: typeof ServersRoute
|
ServersRoute: typeof ServersRoute
|
||||||
@@ -197,6 +210,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/pod': {
|
||||||
|
id: '/pod'
|
||||||
|
path: '/pod'
|
||||||
|
fullPath: '/pod'
|
||||||
|
preLoaderRoute: typeof PodRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/runtime': {
|
'/runtime': {
|
||||||
id: '/runtime'
|
id: '/runtime'
|
||||||
path: '/runtime'
|
path: '/runtime'
|
||||||
@@ -241,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
ErrorsRoute: ErrorsRoute,
|
ErrorsRoute: ErrorsRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
PodRoute: PodRoute,
|
||||||
RuntimeRoute: RuntimeRoute,
|
RuntimeRoute: RuntimeRoute,
|
||||||
SecurityRoute: SecurityRoute,
|
SecurityRoute: SecurityRoute,
|
||||||
ServersRoute: ServersRoute,
|
ServersRoute: ServersRoute,
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ interface RouterContext {
|
|||||||
queryClient: import('@tanstack/react-query').QueryClient
|
queryClient: import('@tanstack/react-query').QueryClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return pathname === '/login' || pathname === '/pod' || pathname.startsWith('/pod/')
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
beforeLoad: ({ location }) => {
|
beforeLoad: ({ location }) => {
|
||||||
if (location.pathname === '/login') return
|
if (isPublicPath(location.pathname)) return
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
throw redirect({ to: '/login' })
|
throw redirect({ to: '/login' })
|
||||||
}
|
}
|
||||||
@@ -18,7 +22,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
if (pathname === '/login') {
|
if (isPublicPath(pathname)) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,8 +97,22 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
created_at TEXT NOT NULL
|
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_jobs_agent ON jobs(agent_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(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_audit_created ON audit_log(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_hierarchy_parent ON user_hierarchy(parent_username);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,3 +67,20 @@ export const auditLog = sqliteTable('audit_log', {
|
|||||||
detailsJson: text('details_json'),
|
detailsJson: text('details_json'),
|
||||||
createdAt: text('created_at').notNull(),
|
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(),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user