feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s

Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 15:42:54 +07:00
co-authored by Cursor
parent f26b2c8777
commit e360efb885
47 changed files with 2675 additions and 237 deletions
+4
View File
@@ -27,6 +27,8 @@ import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
import { authPlugin } from './plugins/auth.js'
import { spacePlugin } from './plugins/space.js'
import { spacesRoutes } from './routes/spaces.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -46,9 +48,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(authPlugin)
await app.register(spacePlugin)
app.get('/health', async () => ({ ok: true }))
await app.register(spacesRoutes)
await app.register(dataRoutes)
await app.register(vpsRoutes)
await app.register(providersRoutes)
+5
View File
@@ -93,6 +93,11 @@ const RULES: Rule[] = [
match: (p) => p.startsWith('/api/sync'),
permission: 'vps:sync:write',
},
{
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) => p.startsWith('/api/spaces'),
permission: 'vps:dashboard:read',
},
{
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
match: (p) =>
+46 -15
View File
@@ -1,5 +1,6 @@
import { timingSafeEqual } from 'node:crypto'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { runWithSpace } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
function safeEqualToken(expected: string, provided: string): boolean {
@@ -20,24 +21,54 @@ export async function requireIntegrationAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const row = settingsRepository.getRow('settings-main')
if (!row?.integrationEnabled) {
return reply.code(403).send({
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
})
}
const expected = settingsRepository.getIntegrationToken()
if (!expected) {
return reply.code(503).send({
error: { code: 'INTEGRATION_NOT_CONFIGURED', message: 'Integration token не настроен' },
})
}
const provided = extractBearer(request)
if (!safeEqualToken(expected, provided)) {
const row = settingsRepository.findByIntegrationToken(provided)
if (!row) {
// Distinguish disabled vs bad token: if any space has integration enabled without match → 401
const anyEnabled = settingsRepository
.listAllSpaces()
.some((r) => r.integrationEnabled && r.integrationToken?.trim())
if (!anyEnabled) {
return reply.code(403).send({
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
})
}
if (!provided) {
return reply.code(503).send({
error: {
code: 'INTEGRATION_NOT_CONFIGURED',
message: 'Integration token не настроен',
},
})
}
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
})
}
if (!safeEqualToken(row.integrationToken!.trim(), provided)) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
})
}
request.spaceId = row.spaceId
// Enter space context for subsequent handlers in this request
// Note: integrations route registers this as onRequest — ALS via runWithSpace won't wrap handler.
// Set header for space plugin skip path — integrations are public for portal JWT.
// Store on request; integrations-cfdm should call runWithSpace when touching DB.
;(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId =
row.spaceId
}
export function runInIntegrationSpace<T>(
request: FastifyRequest,
fn: () => T,
): T {
const spaceId =
(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId ??
request.spaceId ??
'space-main'
return runWithSpace(spaceId, fn)
}
+162
View File
@@ -0,0 +1,162 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import fp from 'fastify-plugin'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { MAIN_SPACE_ID } from '@cfdm/db'
import {
spacesRepository,
roleAtLeast,
type SpaceRole,
} from '@cfdm/db/repositories/spaces'
import { hasPermission } from '../lib/permissions.js'
/** Request-scoped space ALS — entered in onRequest callback form so handlers inherit it. */
const spaceAls = new AsyncLocalStorage<{ spaceId: string }>()
export function getRequestSpaceId(): string {
return spaceAls.getStore()?.spaceId ?? MAIN_SPACE_ID
}
declare module 'fastify' {
interface FastifyRequest {
spaceId?: string
spaceRole?: string
}
}
function isSpacePublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path.startsWith('/api/integrations/cfdm')) return true
return false
}
function headerSpaceId(request: FastifyRequest): string | undefined {
const raw = request.headers['x-space-id']
if (typeof raw === 'string' && raw.trim()) return raw.trim()
if (Array.isArray(raw) && raw[0]?.trim()) return raw[0].trim()
return undefined
}
export async function ensureUserSpaces(request: FastifyRequest): Promise<void> {
const user = request.authUser
if (!user) return
spacesRepository.ensurePersonalSpace(user.id, user.name || user.email)
if (user.isAdmin) {
spacesRepository.claimMainOwnerIfEmpty(user.id)
}
if (hasPermission(user.permissions, 'vps:spaces:admin')) {
const mainMember = spacesRepository.getMember(MAIN_SPACE_ID, user.id)
if (!mainMember) {
spacesRepository.addMember(MAIN_SPACE_ID, user.id, 'admin')
}
}
}
/**
* Bridge request ALS into @cfdm/db space-context by syncing store.
* Handlers use getCurrentSpaceId from @cfdm/db — we enter BOTH stores.
*/
import { runWithSpace } from '@cfdm/db'
export const spacePlugin = fp(async (app) => {
app.addHook('onRequest', (request, reply, done) => {
void (async () => {
try {
if (isSpacePublicPath(request.url) || !request.url.startsWith('/api/')) {
done()
return
}
const authRequired = app.authConfig?.required
if (authRequired && request.authUser) {
await ensureUserSpaces(request)
}
let spaceId = headerSpaceId(request) ?? MAIN_SPACE_ID
if (authRequired && request.authUser) {
const user = request.authUser
const canSpacesAdmin =
Boolean(user.isAdmin) ||
hasPermission(user.permissions, 'vps:spaces:admin')
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
const personal = spacesRepository.ensurePersonalSpace(
user.id,
user.name || user.email,
)
spaceId = personal.id
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Нет доступа к пространству',
},
})
return
}
}
const member = spacesRepository.getMember(spaceId, user.id)
request.spaceRole =
member?.role ?? (canSpacesAdmin ? 'admin' : 'viewer')
} else {
spacesRepository.getMain()
request.spaceRole = 'owner'
}
request.spaceId = spaceId
// Enter ALS for the rest of the request (callback-style keeps context)
runWithSpace(spaceId, () => {
spaceAls.run({ spaceId }, () => {
done()
})
})
} catch (err) {
done(err as Error)
}
})()
})
})
export function requireSpaceRole(
request: FastifyRequest,
reply: FastifyReply,
min: SpaceRole,
): boolean {
const user = request.authUser
const spaceId = request.spaceId ?? MAIN_SPACE_ID
if (!user) {
return true
}
const canSpacesAdmin =
Boolean(user.isAdmin) ||
hasPermission(user.permissions, 'vps:spaces:admin')
const member = spacesRepository.requireRole(
spaceId,
user.id,
min,
canSpacesAdmin,
)
if (!member) {
void reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: `Недостаточно прав в пространстве (нужно: ${min})`,
},
})
return false
}
return true
}
export function canWriteInSpace(request: FastifyRequest): boolean {
const role = request.spaceRole ?? 'viewer'
return roleAtLeast(role, 'member')
}
+14 -9
View File
@@ -2,13 +2,17 @@ import type { FastifyPluginAsync } from 'fastify'
import { cfdmSyncBindingsBodySchema } from '@cfdm/shared/contracts/integration-cfdm'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { requireIntegrationAuth } from '../plugins/integration-auth.js'
import {
requireIntegrationAuth,
runInIntegrationSpace,
} from '../plugins/integration-auth.js'
export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/integrations/cfdm/ping',
{ onRequest: requireIntegrationAuth },
async () => ({ ok: true, service: 'vps-tracker' }),
async (req) =>
runInIntegrationSpace(req, () => ({ ok: true, service: 'vps-tracker' })),
)
app.post(
@@ -22,13 +26,14 @@ export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
})
}
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
settingsRepository.touchIntegrationSync()
return {
ok: true,
...result,
}
return runInIntegrationSpace(req, () => {
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
settingsRepository.touchIntegrationSync()
return {
ok: true,
...result,
}
})
},
)
}
+18 -5
View File
@@ -1,31 +1,44 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js'
import { deliverWebhook } from '../services/notifications/channels.js'
import { requireSpaceRole } from '../plugins/space.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
app.post('/api/settings', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'admin')) return
const parsed = settingsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const id = (req.body as { id?: string })?.id ?? 'settings-main'
const result = settingsRepository.upsert(id, parsed.data)
const spaceId = getCurrentSpaceId()
const id =
(req.body as { id?: string })?.id ?? settingsIdForSpace(spaceId)
const result = settingsRepository.upsertForSpace(spaceId, {
...parsed.data,
})
// Keep id stable
if (result.id !== id) {
/* upsertForSpace picks correct id */
}
restartScheduler()
return reply.code(201).send(result)
})
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'admin')) return
const parsed = settingsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const result = settingsRepository.upsert(req.params.id, parsed.data)
const spaceId = getCurrentSpaceId()
const result = settingsRepository.upsertForSpace(spaceId, parsed.data)
restartScheduler()
return result
})
@@ -33,7 +46,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/settings/telegram/test', async (req) => {
const parsed = telegramTestBodySchema.safeParse(req.body ?? {})
const body = parsed.success ? parsed.data : {}
const settings = settingsRepository.getRow('settings-main')
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || ''
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
@@ -55,7 +68,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
})
app.post('/api/settings/webhook/test', async () => {
const settings = settingsRepository.getRow('settings-main')
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
if (!settings?.webhookEnabled) {
return { ok: false, error: 'Включите webhook в настройках' }
}
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { spacesRepository, vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { buildApp } from '../index.js'
describe('spaces API', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
seedTestProviderAccount()
spacesRepository.getMain()
})
afterEach(() => {
closeDb()
})
it('lists spaces and creates personal space', async () => {
const app = await buildApp()
const res = await app.inject({ method: 'GET', url: '/api/spaces' })
expect(res.statusCode).toBe(200)
const list = res.json() as { id: string }[]
expect(list.some((s) => s.id === MAIN_SPACE_ID)).toBe(true)
await app.close()
})
it('shares and assigns VPS between spaces', async () => {
const personal = spacesRepository.create({
id: 'space-user-u1',
name: 'User 1',
slug: 'user-u1',
kind: 'personal',
ownerUserId: 'u1',
})
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '1.2.3.4',
providerId: 'prov-1',
providerAccountId: 'acc-1',
status: 'active',
}),
)
const grant = vpsGrantsRepository.create({
vpsId: vps.id,
fromSpaceId: MAIN_SPACE_ID,
toSpaceId: personal.id,
permission: 'write',
grantedByUserId: 'admin',
})
expect(grant.permission).toBe('write')
const sharedList = runWithSpace(personal.id, () => {
const grants = vpsGrantsRepository.listToSpace(personal.id)
return vpsRepository.listByIds(grants.map((g) => g.vpsId))
})
expect(sharedList).toHaveLength(1)
expect(sharedList[0]?.id).toBe(vps.id)
vpsGrantsRepository.deleteByVps(vps.id)
const moved = vpsRepository.assignToSpace(vps.id, personal.id)
expect(moved?.spaceId).toBe(personal.id)
expect(moved?.providerAccountId).toBeFalsy()
const stillInMain = runWithSpace(MAIN_SPACE_ID, () => vpsRepository.get(vps.id))
expect(stillInMain).toBeUndefined()
const inPersonal = runWithSpace(personal.id, () => vpsRepository.get(vps.id))
expect(inPersonal?.id).toBe(vps.id)
})
it('share endpoint via inject', async () => {
const personal = spacesRepository.create({
id: 'space-user-u2',
name: 'User 2',
slug: 'user-u2',
kind: 'personal',
ownerUserId: 'u2',
})
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '10.0.0.1',
providerId: 'prov-1',
providerAccountId: 'acc-1',
status: 'active',
}),
)
// Same process DB as resetTestDb (:memory: already set)
const app = await buildApp()
const res = await app.inject({
method: 'POST',
url: `/api/spaces/${MAIN_SPACE_ID}/vps/${vps.id}/share`,
headers: { 'x-space-id': MAIN_SPACE_ID },
payload: { toSpaceId: personal.id, permission: 'read' },
})
expect(res.statusCode).toBe(201)
const body = res.json() as { toSpaceId: string; permission: string }
expect(body.toSpaceId).toBe(personal.id)
expect(body.permission).toBe('read')
await app.close()
})
})
+256
View File
@@ -0,0 +1,256 @@
import type { FastifyPluginAsync } from 'fastify'
import { MAIN_SPACE_ID } from '@cfdm/db'
import {
spacesRepository,
vpsGrantsRepository,
type SpaceRole,
type GrantPermission,
} from '@cfdm/db/repositories/spaces'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { hasPermission } from '../lib/permissions.js'
import { requireSpaceRole } from '../plugins/space.js'
const ROLES: SpaceRole[] = ['owner', 'admin', 'member', 'viewer']
function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: string[] } }) {
const u = request.authUser
if (!u) return true
return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin')
}
export const spacesRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/spaces', async (req) => {
const user = req.authUser
if (!user) {
return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' }))
}
return spacesRepository.listForUser(user.id, isSpacesAdmin(req))
})
app.post('/api/spaces', async (req, reply) => {
const user = req.authUser
if (!user) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const body = req.body as { name?: string; slug?: string }
const name = String(body.name ?? '').trim() || 'Новое пространство'
const slug =
String(body.slug ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-') || `space-${Date.now()}`
const created = spacesRepository.create({
name,
slug,
kind: 'personal',
ownerUserId: user.id,
})
return reply.code(201).send({ ...created, role: 'owner' })
})
app.get<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
const space = spacesRepository.get(req.params.id)
if (!space) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
const user = req.authUser
if (user && !spacesRepository.canAccess(space.id, user.id, isSpacesAdmin(req))) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
}
const member = user
? spacesRepository.getMember(space.id, user.id)
: { role: 'owner' }
return { ...space, role: member?.role ?? 'viewer' }
})
app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
// Temporarily set space for role check
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { name?: string; slug?: string }
const updated = spacesRepository.update(req.params.id, {
...(body.name !== undefined ? { name: String(body.name).trim() } : {}),
...(body.slug !== undefined
? { slug: String(body.slug).trim().toLowerCase() }
: {}),
})
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/members',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'viewer')) return
return spacesRepository.listMembers(req.params.id)
},
)
app.post<{ Params: { id: string } }>(
'/api/spaces/:id/members',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { userId?: string; role?: string }
const userId = String(body.userId ?? '').trim()
if (!userId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'userId обязателен' },
})
}
const role = (ROLES.includes(body.role as SpaceRole)
? body.role
: 'member') as SpaceRole
if (role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя назначить owner через invite' },
})
}
const member = spacesRepository.addMember(req.params.id, userId, role)
return reply.code(201).send(member)
},
)
app.patch<{ Params: { id: string; userId: string } }>(
'/api/spaces/:id/members/:userId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { role?: string }
const role = body.role as SpaceRole
if (!ROLES.includes(role) || role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Некорректная роль' },
})
}
const updated = spacesRepository.updateMember(
req.params.id,
req.params.userId,
role,
)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
},
)
app.delete<{ Params: { id: string; userId: string } }>(
'/api/spaces/:id/members/:userId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const member = spacesRepository.getMember(req.params.id, req.params.userId)
if (member?.role === 'owner') {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя удалить владельца' },
})
}
const ok = spacesRepository.removeMember(req.params.id, req.params.userId)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
},
)
/** Share VPS from current ownership space to target space */
app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/share',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { toSpaceId?: string; permission?: string }
const toSpaceId = String(body.toSpaceId ?? '').trim()
if (!toSpaceId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
})
}
if (!spacesRepository.get(toSpaceId)) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
})
}
const vps = vpsRepository.getAnySpace(req.params.vpsId)
if (!vps || vps.spaceId !== req.params.id) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
})
}
const permission: GrantPermission =
body.permission === 'write' ? 'write' : 'read'
const grant = vpsGrantsRepository.create({
vpsId: req.params.vpsId,
fromSpaceId: req.params.id,
toSpaceId,
permission,
grantedByUserId: req.authUser?.id ?? null,
})
return reply.code(201).send(grant)
},
)
app.delete<{ Params: { id: string; grantId: string } }>(
'/api/spaces/:id/vps-grants/:grantId',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const grant = vpsGrantsRepository.get(req.params.grantId)
if (!grant || (grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
vpsGrantsRepository.delete(req.params.grantId)
return reply.code(204).send()
},
)
/** Assign (move) VPS to target space */
app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/assign',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { toSpaceId?: string }
const toSpaceId = String(body.toSpaceId ?? '').trim()
if (!toSpaceId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
})
}
if (!spacesRepository.get(toSpaceId)) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
})
}
const vps = vpsRepository.getAnySpace(req.params.vpsId)
if (!vps || vps.spaceId !== req.params.id) {
return reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
})
}
vpsGrantsRepository.deleteByVps(req.params.vpsId)
const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
return moved
},
)
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/vps-grants',
async (req, reply) => {
req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'viewer')) return
return {
incoming: vpsGrantsRepository.listToSpace(req.params.id),
outgoing: vpsGrantsRepository.listFromSpace(req.params.id),
}
},
)
}
export { MAIN_SPACE_ID }
+66 -7
View File
@@ -1,13 +1,41 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
import { getCurrentSpaceId } from '@cfdm/db'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
export const vpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/vps', async () => vpsRepository.list())
app.get('/api/vps', async () => {
const owned = vpsRepository.list()
const spaceId = getCurrentSpaceId()
const grants = vpsGrantsRepository.listToSpace(spaceId)
const ownedIds = new Set(owned.map((v) => v.id))
const shared = vpsRepository
.listByIds(grants.map((g) => g.vpsId).filter((id) => !ownedIds.has(id)))
.map((v) => {
const g = grants.find((x) => x.vpsId === v.id)
return {
...v,
access: 'shared' as const,
grantPermission: (g?.permission === 'write' ? 'write' : 'read') as
| 'read'
| 'write',
providerAccountId: '',
}
})
return [...owned, ...shared]
})
app.post('/api/vps', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
if (!canWriteInSpace(req)) {
return reply.code(403).send({
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
})
}
const parsed = vpsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
@@ -24,18 +52,48 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = vpsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
const owned = vpsRepository.get(req.params.id)
if (owned) {
if (!requireSpaceRole(req, reply, 'member')) return
const updated = vpsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
vpsDomainsRepository.rematchAll()
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
}
vpsDomainsRepository.rematchAll()
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
// Shared write?
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
if (grant?.permission === 'write') {
if (!requireSpaceRole(req, reply, 'member')) return
const updated = vpsRepository.updateAnySpace(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return { ...updated, access: 'shared', grantPermission: 'write' }
}
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
const ok = vpsRepository.delete(req.params.id)
if (!ok) {
// Shared VPS cannot be deleted from grantee space
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
if (grant) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Общий VPS можно только отозвать у владельца, не удалить',
},
})
}
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditDelete('vps', req.params.id)
@@ -43,6 +101,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
})
app.patch('/api/vps/bulk', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'member')) return
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
const ids = Array.isArray(body.ids) ? body.ids : []
if (ids.length === 0) {
+2 -4
View File
@@ -2,10 +2,8 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm'
const SETTINGS_ID = 'settings-main'
function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row) return null
const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '')
@@ -19,7 +17,7 @@ export async function notifyCfdmVpsEvent(
): Promise<void> {
if (vpsIds.length === 0) return
const row = settingsRepository.getRow(SETTINGS_ID)
const row = settingsRepository.getBySpace()
if (!row?.integrationEnabled) return
const token = settingsRepository.getIntegrationToken()
@@ -22,6 +22,7 @@ import {
const account: FourvpsSyncAccount = {
id: 'acc-4vps',
spaceId: 'space-main',
providerId: 'prov-4vps',
name: '4VPS Account',
panelUrl: '',
+4 -6
View File
@@ -1,9 +1,5 @@
/**
* Generic account sync with sync_log recording
*/
import { eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getDb, schema, getCurrentSpaceId } from '@cfdm/db'
import type { ProviderAdapter, SyncResult } from './types.js'
@@ -18,12 +14,14 @@ export async function runAccountSync(
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
): Promise<RunAccountSyncResult> {
const db = getDb()
const accountRow = account as { id: string }
const accountRow = account as { id: string; spaceId?: string }
const logId = `sync-${accountRow.id}-${Date.now()}`
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
db.insert(schema.syncLog)
.values({
id: logId,
spaceId,
accountId: accountRow.id,
startedAt: new Date().toISOString(),
status: 'running',
+1
View File
@@ -33,6 +33,7 @@ import {
function makeAccount(): RuvdsSyncAccount {
return {
id: 'acc-ruvds',
spaceId: 'space-main',
providerId: 'prov-ruvds',
name: 'RuVDS',
panelUrl: '',
+184 -111
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { eq, sql } from 'drizzle-orm'
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
@@ -20,26 +20,30 @@ let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type SettingsRow = typeof schema.settings.$inferSelect
interface SyncableAccountEntry {
account: SyncReadyAccount
apiType: string
}
function getSyncableAccounts(): SyncableAccountEntry[] {
function getSyncableAccounts(spaceId: string): SyncableAccountEntry[] {
const db = getDb()
const rows = db
.all<AccountRow>(sql`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
WHERE pa.spaceId = ${spaceId}
AND lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`)
const providers = db.select().from(schema.providers).all()
const providers = db
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const providerById = new Map(providers.map((p) => [p.id, p]))
return rows
.map((a) => {
@@ -49,123 +53,184 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
.filter((e): e is SyncableAccountEntry => e != null)
}
function allSettings(): SettingsRow[] {
return settingsRepository.listAllSpaces()
}
export async function runNotificationTick(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
} catch (err) {
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
})
} catch (err) {
console.warn(
`Notification tick error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
}
}
export async function runScheduledSync(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const entries = getSyncableAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(
`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`,
)
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipTariffs: true })
const s = result.syncSummary
const parts: string[] = []
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
digestLines.push(`${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
apiBal != null &&
Number.isFinite(Number(apiBal)) &&
Number(apiBal) < Number(threshold)
) {
const cur =
result.balance?.currency || account.balanceCurrency || account.currency || ''
lowBalanceLines.push(`${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'ошибка'
digestLines.push(`${account.name}: ${message}`)
}
}
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
})
} catch (err) {
console.warn(`Scheduled sync error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
}
}
export async function runScheduledSyncTariffs(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
for (const settings of allSettings()) {
if (!settings.syncEnabled) continue
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const entries = getSyncableAccounts(spaceId)
const providers = getDb()
.select()
.from(schema.providers)
.where(eq(schema.providers.spaceId, spaceId))
.all()
const entries = getSyncableAccounts()
const providers = getDb().select().from(schema.providers).all()
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
for (const { account, apiType } of entries) {
try {
const adapter = getProviderAdapter(apiType)
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
}
} catch (err) {
console.warn(
`Sync tariffs failed for account ${account.id}:`,
err instanceof Error ? err.message : err,
)
}
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
}
})
} catch (err) {
console.warn(
`Scheduled sync tariffs error [${spaceId}]:`,
err instanceof Error ? err.message : err,
)
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
}
}
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
for (const settings of allSettings()) {
const spaceId = settings.spaceId || MAIN_SPACE_ID
try {
await runWithSpaceAsync(spaceId, async () => {
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
}
})
} catch (err) {
console.warn(`Uptime check error [${spaceId}]:`, err instanceof Error ? err.message : err)
}
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
}
function pickSchedulerIntervals(rows: SettingsRow[]): {
notifyInterval: number
uptimeInterval: number
syncInterval: number | null
tariffsInterval: number | null
} {
let notifyInterval = 60
let uptimeInterval = 5
let syncInterval: number | null = null
let tariffsInterval: number | null = null
for (const s of rows) {
notifyInterval = Math.min(
notifyInterval,
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
)
uptimeInterval = Math.min(
uptimeInterval,
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
)
if (s.syncEnabled) {
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
}
}
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
}
export function startScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
@@ -177,28 +242,36 @@ export function startScheduler(): void {
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const rows = allSettings()
if (rows.length === 0) return
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
const { notifyInterval, uptimeInterval, syncInterval, tariffsInterval } =
pickSchedulerIntervals(rows)
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
uptimeIntervalId = setInterval(
() => void runScheduledUptimeChecks(),
uptimeInterval * 60 * 1000,
)
void runNotificationTick()
void runScheduledUptimeChecks()
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
const parts = [
`notify every ${notifyInterval} min`,
`uptime every ${uptimeInterval} min`,
`spaces=${rows.length}`,
]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
if (syncInterval != null) {
syncIntervalId = setInterval(() => void runScheduledSync(), syncInterval * 60 * 1000)
parts.unshift(`sync every ${syncInterval} min`)
}
if (tariffsInterval != null) {
syncTariffsIntervalId = setInterval(
() => void runScheduledSyncTariffs(),
tariffsInterval * 60 * 1000,
)
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
parts.unshift(`tariffs every ${tariffsInterval} min`)
}
console.log(`Scheduler: ${parts.join(', ')}`)
@@ -29,6 +29,7 @@ import {
function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
return {
id: `acc-${apiType}`,
spaceId: 'space-main',
providerId: `prov-${apiType}`,
name: `${apiType} Account`,
panelUrl: '',
+1
View File
@@ -22,6 +22,7 @@ import { fetchBalance, fetchInvoices, fetchTariffList, fetchVpsRecords } from '.
function makeAccount(): VeespSyncAccount {
return {
id: 'acc-veesp',
spaceId: 'space-main',
providerId: 'prov-veesp',
name: 'Veesp',
panelUrl: '',
@@ -12,6 +12,7 @@ import {
RefreshCwIcon,
FolderKanbanIcon,
HistoryIcon,
UsersIcon,
} from 'lucide-react'
import {
@@ -48,6 +49,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
@@ -101,6 +103,7 @@ const NAV_GROUPS: NavGroup[] = [
{
label: 'Система',
items: [
{ to: '/spaces', label: 'Пространство', icon: UsersIcon },
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
{ to: '/audit', label: 'Журнал изменений', icon: HistoryIcon },
{ to: '/settings', label: 'Настройки', icon: Settings },
@@ -127,6 +130,7 @@ const PARENT_ROUTE: Record<string, string> = {
'/renewals': '/dashboard',
'/sync-journal': '/settings',
'/audit': '/settings',
'/spaces': '/settings',
}
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
@@ -169,6 +173,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
<SpaceSwitcher />
</SidebarHeader>
<SidebarContent>
{navGroups.map((group) => (
@@ -0,0 +1,139 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import { Input } from '@cfdm/ui/components/input'
import { Label } from '@cfdm/ui/components/label'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@cfdm/ui/components/sidebar'
import { api } from '@/lib/api-client'
import { getStoredSpaceId, setStoredSpaceId, type SpaceDto } from '@/lib/space'
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
export function SpaceSwitcher() {
const qc = useQueryClient()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const currentId = getStoredSpaceId() ?? spaces[0]?.id
const current = spaces.find((s) => s.id === currentId) ?? spaces[0]
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
useEffect(() => {
if (!getStoredSpaceId() && spaces[0]?.id) {
setStoredSpaceId(spaces[0].id)
}
}, [spaces])
function selectSpace(space: SpaceDto) {
setStoredSpaceId(space.id)
void qc.invalidateQueries({ queryKey: snapshotKeys.all })
void qc.invalidateQueries({ queryKey: spacesKeys.all })
toast.success(`Пространство: ${space.name}`)
}
async function handleCreate() {
const n = name.trim()
if (!n) return
try {
const created = await api.createSpace({ name: n })
setStoredSpaceId(created.id)
setCreateOpen(false)
setName('')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
toast.success('Пространство создано')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Ошибка создания')
}
}
if (spaces.length === 0) {
return (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Пространства</div>
)
}
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />}
>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{current?.name ?? 'Пространство'}</span>
<span className="truncate text-xs text-muted-foreground">
{current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56 rounded-lg" align="start" sideOffset={4}>
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => (
<DropdownMenuItem
key={s.id}
onClick={() => selectSpace(s)}
className={s.id === current?.id ? 'bg-accent' : undefined}
>
<span className="truncate">{s.name}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Создать пространство
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Новое пространство</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="space-name">Название</Label>
<Input
id="space-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Моя команда"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Отмена
</Button>
<Button onClick={() => void handleCreate()}>Создать</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import { Label } from '@cfdm/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { api } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
import type { Vps } from '@/types/entities'
type Props = {
vps: Vps | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
const qc = useQueryClient()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const fromSpaceId = getStoredSpaceId() ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
const targets = spaces.filter((s) => s.id !== fromSpaceId)
const [toSpaceId, setToSpaceId] = useState('')
const [permission, setPermission] = useState<'read' | 'write'>('read')
const shareMutation = useMutation({
mutationFn: () =>
api.shareVps(fromSpaceId, vps!.id, {
toSpaceId,
permission,
}),
onSuccess: async () => {
toast.success('Доступ выдан (share)')
onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
const assignMutation = useMutation({
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId),
onSuccess: async () => {
toast.success('Сервер перенесён (assign)')
onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Доступ к серверу</SheetTitle>
<SheetDescription>
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2">
<Label>Целевое пространство</Label>
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Выберите пространство" />
</SelectTrigger>
<SelectContent>
{targets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Права (для share)</Label>
<Select
value={permission}
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">read</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
<SheetFooter className="flex-col gap-2 sm:flex-col">
<Button
disabled={!toSpaceId || !vps || shareMutation.isPending}
onClick={() => shareMutation.mutate()}
>
Share (ACL)
</Button>
<Button
variant="outline"
disabled={!toSpaceId || !vps || assignMutation.isPending}
onClick={() => {
if (
!window.confirm(
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.',
)
) {
return
}
assignMutation.mutate()
}}
>
Assign (перенос)
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+64
View File
@@ -9,6 +9,7 @@ import type {
BalanceLedgerRow,
} from '@/types/entities'
import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
import { getStoredSpaceId } from '@/lib/space'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -32,6 +33,10 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const spaceId = getStoredSpaceId()
if (spaceId && !headers.has('X-Space-Id')) {
headers.set('X-Space-Id', spaceId)
}
const res = await fetch(url, {
...options,
headers,
@@ -161,6 +166,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -179,6 +186,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -193,6 +202,61 @@ export const api = {
return res.blob()
},
fetchSpaces: () => fetchApi<import('@/lib/space').SpaceDto[]>('/api/spaces'),
createSpace: (body: { name: string; slug?: string }) =>
fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', {
method: 'POST',
body: JSON.stringify(body),
}),
fetchSpaceMembers: (spaceId: string) =>
fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>(
`/api/spaces/${encodeURIComponent(spaceId)}/members`,
),
addSpaceMember: (spaceId: string, body: { userId: string; role?: string }) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateSpaceMember: (spaceId: string, userId: string, role: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify({ role }),
}),
removeSpaceMember: (spaceId: string, userId: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
}),
shareVps: (
fromSpaceId: string,
vpsId: string,
body: { toSpaceId: string; permission: 'read' | 'write' },
) =>
fetchApi(`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/share`, {
method: 'POST',
body: JSON.stringify(body),
}),
assignVps: (fromSpaceId: string, vpsId: string, toSpaceId: string) =>
fetchApi(
`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/assign`,
{
method: 'POST',
body: JSON.stringify({ toSpaceId }),
},
),
fetchSpaceGrants: (spaceId: string) =>
fetchApi<{
incoming: unknown[]
outgoing: unknown[]
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`),
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
+5 -1
View File
@@ -199,7 +199,11 @@ export function permissionForPath(pathname: string): string | null {
return 'vps:payments:read'
}
if (pathname.startsWith('/sync-journal')) return 'vps:sync:write'
if (pathname.startsWith('/settings') || pathname.startsWith('/audit')) {
if (
pathname.startsWith('/settings') ||
pathname.startsWith('/audit') ||
pathname.startsWith('/spaces')
) {
return 'vps:settings:admin'
}
return 'vps:dashboard:read'
+35
View File
@@ -0,0 +1,35 @@
const STORAGE_KEY = 'vps_space_id'
export type SpaceDto = {
id: string
name: string
slug: string
kind: string
ownerUserId: string | null
createdAt: string
role?: string
}
export function getStoredSpaceId(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
export function setStoredSpaceId(id: string): void {
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
/* ignore */
}
}
export function clearStoredSpaceId(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
+14 -1
View File
@@ -1,16 +1,29 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
import { getStoredSpaceId } from '../lib/space'
export const snapshotKeys = {
all: ['snapshot'] as const,
space: (spaceId: string | null) => ['snapshot', spaceId ?? 'default'] as const,
}
export const snapshotQueryOptions = () => ({
queryKey: snapshotKeys.all,
queryKey: snapshotKeys.space(getStoredSpaceId()),
queryFn: () => api.fetchData(),
staleTime: 30_000,
})
export const spacesKeys = {
all: ['spaces'] as const,
members: (spaceId: string) => ['spaces', spaceId, 'members'] as const,
}
export const spacesQueryOptions = () => ({
queryKey: spacesKeys.all,
queryFn: () => api.fetchSpaces(),
staleTime: 60_000,
})
export const ratesKeys = {
all: ['rates'] as const,
}
+21
View File
@@ -15,6 +15,7 @@ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
@@ -60,6 +61,11 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
path: '/sync-journal',
getParentRoute: () => AuthRoute,
} as any)
const AuthSpacesRoute = AuthSpacesRouteImport.update({
id: '/spaces',
path: '/spaces',
getParentRoute: () => AuthRoute,
} as any)
const AuthResourcesRoute = AuthResourcesRouteImport.update({
id: '/resources',
path: '/resources',
@@ -150,6 +156,7 @@ export interface FileRoutesByFullPath {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
@@ -171,6 +178,7 @@ export interface FileRoutesByTo {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/spaces': typeof AuthSpacesRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
@@ -195,6 +203,7 @@ export interface FileRoutesById {
'/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute
'/_auth/resources': typeof AuthResourcesRoute
'/_auth/spaces': typeof AuthSpacesRoute
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
@@ -219,6 +228,7 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/vps'
@@ -240,6 +250,7 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/spaces'
| '/sync-journal'
| '/tariffs'
| '/vps'
@@ -263,6 +274,7 @@ export interface FileRouteTypes {
| '/_auth/renewals'
| '/_auth/reports'
| '/_auth/resources'
| '/_auth/spaces'
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/vps'
@@ -323,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSyncJournalRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/spaces': {
id: '/_auth/spaces'
path: '/spaces'
fullPath: '/spaces'
preLoaderRoute: typeof AuthSpacesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/resources': {
id: '/_auth/resources'
path: '/resources'
@@ -479,6 +498,7 @@ interface AuthRouteChildren {
AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute
AuthResourcesRoute: typeof AuthResourcesRoute
AuthSpacesRoute: typeof AuthSpacesRoute
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren
@@ -496,6 +516,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute,
AuthResourcesRoute: AuthResourcesRoute,
AuthSpacesRoute: AuthSpacesRoute,
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthVpsRoute: AuthVpsRouteWithChildren,
+166
View File
@@ -0,0 +1,166 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Label } from '@cfdm/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { api } from '@/lib/api-client'
import { getStoredSpaceId } from '@/lib/space'
import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot'
export const Route = createFileRoute('/_auth/spaces')({
component: SpacesPage,
})
type MemberRow = {
spaceId: string
userId: string
role: string
createdAt: string
}
function SpacesPage() {
const qc = useQueryClient()
const spaceId = getStoredSpaceId()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const current = spaces.find((s) => s.id === spaceId) ?? spaces[0]
const currentId = current?.id ?? ''
const membersQuery = useQuery({
queryKey: spacesKeys.members(currentId),
queryFn: () => api.fetchSpaceMembers(currentId),
enabled: Boolean(currentId),
})
const [userId, setUserId] = useState('')
const [role, setRole] = useState('member')
const addMutation = useMutation({
mutationFn: () =>
api.addSpaceMember(currentId, { userId: userId.trim(), role }),
onSuccess: async () => {
setUserId('')
toast.success('Участник добавлен')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
const removeMutation = useMutation({
mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid),
onSuccess: async () => {
toast.success('Участник удалён')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
return (
<PageShell>
<PageHeader
title="Пространство"
description={
current
? `${current.name} (${current.kind === 'main' ? 'основное' : 'личное'})`
: 'Участники и доступ'
}
/>
<QueryState
data={membersQuery.data as MemberRow[] | undefined}
isLoading={membersQuery.isLoading}
isError={membersQuery.isError}
error={membersQuery.error}
onRetry={() => void membersQuery.refetch()}
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)}
emptyTitle="Нет участников"
emptyDescription="Добавьте userId из auth-portal"
>
{(members) => (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-end">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="member-user-id">User ID (из auth-portal)</Label>
<Input
id="member-user-id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="uuid пользователя"
/>
</div>
<div className="flex w-full flex-col gap-2 md:w-40">
<Label>Роль</Label>
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
<SelectItem value="viewer">viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button
disabled={!userId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={`${m.spaceId}-${m.userId}`}>
<TableCell className="font-mono text-xs">{m.userId}</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
{m.role !== 'owner' ? (
<Button
variant="outline"
size="sm"
onClick={() => removeMutation.mutate(m.userId)}
>
Удалить
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</QueryState>
</PageShell>
)
}
+34 -6
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo, useEffect } from 'react'
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon } from 'lucide-react'
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon, Share2Icon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
@@ -32,6 +32,7 @@ import { HealthModeBanner } from '@/components/health-mode-banner'
import { ProjectColorDot } from '@/components/project-color-dot'
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
import { VpsAccessSheet } from '@/components/vps-access-sheet'
import type { Vps } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
@@ -66,6 +67,8 @@ function VpsPage() {
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [accessVps, setAccessVps] = useState<Vps | null>(null)
const [accessOpen, setAccessOpen] = useState(false)
useEffect(() => {
if (!health) return
@@ -414,9 +417,14 @@ function VpsPage() {
header: 'Статус',
icon: CircleDotIcon,
cell: (v) => (
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
<div className="flex items-center gap-1.5">
{v.access === 'shared' ? (
<Badge variant="outline">Общий</Badge>
) : null}
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
</div>
),
},
{
@@ -490,10 +498,25 @@ function VpsPage() {
className: 'w-24 text-right',
cell: (v) => (
<RowActions
onEdit={() => openEdit(v)}
onDelete={() => deleteMutation.mutate(v.id)}
onEdit={v.access === 'shared' && v.grantPermission !== 'write' ? undefined : () => openEdit(v)}
onDelete={v.access === 'shared' ? undefined : () => deleteMutation.mutate(v.id)}
deleteTitle="Удалить VPS?"
deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`}
extra={
v.access !== 'shared' ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Доступ"
onClick={() => {
setAccessVps(v)
setAccessOpen(true)
}}
>
<Share2Icon />
</Button>
) : null
}
/>
),
},
@@ -661,6 +684,11 @@ function VpsPage() {
submitting={createMutation.isPending || updateMutation.isPending}
/>
) : null}
<VpsAccessSheet
vps={accessVps}
open={accessOpen}
onOpenChange={setAccessOpen}
/>
</PageShell>
)
}
+3
View File
@@ -83,6 +83,9 @@ export interface Vps {
paidUntil?: string
notes?: string
customData?: string | Record<string, string | number | boolean>
access?: 'owned' | 'shared'
grantPermission?: 'read' | 'write'
spaceId?: string
}
export interface Payment {