feat(spaces): корзина, hard purge, transfer и полный backup
Docker / build (push) Failing after 20s

Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 12:50:18 +07:00
co-authored by Cursor
parent 74af90270f
commit 670b5a4468
19 changed files with 1131 additions and 167 deletions
+2
View File
@@ -30,6 +30,7 @@ import { startScheduler } from './services/scheduler.js'
import { authPlugin } from './plugins/auth.js' import { authPlugin } from './plugins/auth.js'
import { spacePlugin } from './plugins/space.js' import { spacePlugin } from './plugins/space.js'
import { spacesRoutes } from './routes/spaces.js' import { spacesRoutes } from './routes/spaces.js'
import { portalUsersRoutes } from './routes/portal-users.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.get('/health', async () => ({ ok: true })) app.get('/health', async () => ({ ok: true }))
await app.register(spacesRoutes) await app.register(spacesRoutes)
await app.register(portalUsersRoutes)
await app.register(dataRoutes) await app.register(dataRoutes)
await app.register(vpsRoutes) await app.register(vpsRoutes)
await app.register(providersRoutes) await app.register(providersRoutes)
+17 -19
View File
@@ -1,20 +1,21 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { desc } from 'drizzle-orm'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { import {
getDb,
getDbPath, getDbPath,
readDatabaseFileBuffer, readDatabaseFileBuffer,
reloadDatabaseFromBuffer, reloadDatabaseFromBuffer,
schema,
} from '@cfdm/db' } from '@cfdm/db'
import { getSnapshot } from '@cfdm/db/repositories/snapshot' import { spacesRepository } from '@cfdm/db/repositories/spaces'
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js' import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
import {
FULL_BACKUP_VERSION,
getFullBackupSnapshot,
importFullBackup,
type FullBackupPayload,
} from '../services/backup-full.js'
import { restartScheduler } from '../services/scheduler.js' import { restartScheduler } from '../services/scheduler.js'
const BACKUP_VERSION = 1
/** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */ /** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */
function backupBodyLimitBytes(): number { function backupBodyLimitBytes(): number {
const raw = process.env.BACKUP_BODY_LIMIT_BYTES const raw = process.env.BACKUP_BODY_LIMIT_BYTES
@@ -37,18 +38,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
) )
app.get('/api/backup/json', async (_req, reply) => { app.get('/api/backup/json', async (_req, reply) => {
const syncLog = getDb() const snapshot = getFullBackupSnapshot()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(500)
.all()
const snapshot = {
backupVersion: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
...getSnapshot(),
syncLog,
}
reply.header('Content-Type', 'application/json; charset=utf-8') reply.header('Content-Type', 'application/json; charset=utf-8')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"') reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
return reply.send(JSON.stringify(snapshot, null, 2)) return reply.send(JSON.stringify(snapshot, null, 2))
@@ -74,7 +64,14 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } }) return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
} }
try { try {
importJsonSnapshot(payload as BackupPayload) const data = payload as FullBackupPayload
if (data.tables && typeof data.tables === 'object') {
importFullBackup(data)
} else {
importJsonSnapshot(payload as BackupPayload)
}
void FULL_BACKUP_VERSION
spacesRepository.getMain()
restartScheduler() restartScheduler()
return { ok: true } return { ok: true }
} catch (err) { } catch (err) {
@@ -95,6 +92,7 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
} }
try { try {
reloadDatabaseFromBuffer(buf) reloadDatabaseFromBuffer(buf)
spacesRepository.getMain()
restartScheduler() restartScheduler()
return { ok: true } return { ok: true }
} catch (err) { } catch (err) {
+52
View File
@@ -0,0 +1,52 @@
import type { FastifyPluginAsync } from 'fastify'
/**
* Proxy portal directory users so the SPA searches by name/email
* without calling auth-portal CORS from the browser.
*/
export const portalUsersRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Querystring: { q?: string } }>('/api/portal-users', async (req, reply) => {
const portalUrl = app.authConfig?.portalUrl
if (!portalUrl) {
return reply.code(503).send({
error: { code: 'UNAVAILABLE', message: 'AUTH_PORTAL_URL не настроен' },
})
}
const auth = req.headers.authorization
if (!auth) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const q = String(req.query.q ?? '').trim()
const url = new URL('/api/v1/directory/users', portalUrl)
if (q) url.searchParams.set('q', q)
try {
const res = await fetch(url, {
headers: { Authorization: auth, Accept: 'application/json' },
})
if (!res.ok) {
const text = await res.text()
return reply.code(res.status).send({
error: {
code: 'PORTAL_ERROR',
message: text || `Portal HTTP ${res.status}`,
},
})
}
const data = (await res.json()) as { id: string; email: string; name: string }[]
return Array.isArray(data) ? data : []
} catch (err) {
req.log.error(err)
return reply.code(502).send({
error: {
code: 'BAD_GATEWAY',
message: err instanceof Error ? err.message : 'Ошибка portal',
},
})
}
})
}
+108
View File
@@ -103,4 +103,112 @@ describe('spaces API', () => {
expect(body.permission).toBe('read') expect(body.permission).toBe('read')
await app.close() await app.close()
}) })
it('soft-delete → restore; soft → purge; purge active/main → 400', async () => {
const personal = spacesRepository.create({
id: 'space-user-trash',
name: 'Trashable',
slug: 'trashable',
kind: 'personal',
ownerUserId: 'u-trash',
})
runWithSpace(personal.id, () =>
vpsRepository.create({
ip: '9.9.9.9',
providerId: 'prov-1',
providerAccountId: 'acc-1',
status: 'active',
}),
)
const app = await buildApp()
const soft = await app.inject({
method: 'DELETE',
url: `/api/spaces/${personal.id}`,
})
expect(soft.statusCode).toBe(200)
expect((soft.json() as { deletedAt: string }).deletedAt).toBeTruthy()
const activeList = await app.inject({ method: 'GET', url: '/api/spaces' })
expect(
(activeList.json() as { id: string }[]).some((s) => s.id === personal.id),
).toBe(false)
const trashList = await app.inject({
method: 'GET',
url: '/api/spaces?deleted=1',
})
expect(
(trashList.json() as { id: string }[]).some((s) => s.id === personal.id),
).toBe(true)
const restore = await app.inject({
method: 'POST',
url: `/api/spaces/${personal.id}/restore`,
})
expect(restore.statusCode).toBe(200)
expect((restore.json() as { deletedAt: string | null }).deletedAt).toBeFalsy()
// soft again then purge
await app.inject({ method: 'DELETE', url: `/api/spaces/${personal.id}` })
const purgeActive = await app.inject({
method: 'DELETE',
url: `/api/spaces/${MAIN_SPACE_ID}/purge`,
})
expect(purgeActive.statusCode).toBe(400)
const purgeBeforeTrash = await app.inject({
method: 'DELETE',
url: `/api/spaces/${personal.id}`,
})
// already soft-deleted — soft again returns existing
expect(purgeBeforeTrash.statusCode).toBe(200)
const stillActive = spacesRepository.create({
id: 'space-user-active-purge',
name: 'Active',
slug: 'active-purge',
kind: 'personal',
ownerUserId: 'u-a',
})
const purgeActiveSpace = await app.inject({
method: 'DELETE',
url: `/api/spaces/${stillActive.id}/purge`,
})
expect(purgeActiveSpace.statusCode).toBe(400)
const purged = await app.inject({
method: 'DELETE',
url: `/api/spaces/${personal.id}/purge`,
})
expect(purged.statusCode).toBe(204)
expect(spacesRepository.getAny(personal.id)).toBeUndefined()
expect(runWithSpace(personal.id, () => vpsRepository.list()).length).toBe(0)
await app.close()
})
it('transfers ownership', async () => {
const personal = spacesRepository.create({
id: 'space-user-xfer',
name: 'Xfer',
slug: 'xfer',
kind: 'personal',
ownerUserId: 'owner-a',
})
const app = await buildApp()
const res = await app.inject({
method: 'POST',
url: `/api/spaces/${personal.id}/transfer-ownership`,
payload: { newOwnerUserId: 'owner-b' },
})
expect(res.statusCode).toBe(200)
const body = res.json() as { ownerUserId: string }
expect(body.ownerUserId).toBe('owner-b')
expect(spacesRepository.getMember(personal.id, 'owner-a')?.role).toBe('admin')
expect(spacesRepository.getMember(personal.id, 'owner-b')?.role).toBe('owner')
await app.close()
})
}) })
+130 -8
View File
@@ -18,13 +18,29 @@ function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: s
return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin') return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin')
} }
function canManageDeletedSpace(
space: { ownerUserId: string | null; id: string },
userId: string | undefined,
admin: boolean,
): boolean {
if (admin) return true
if (!userId) return true
if (space.ownerUserId === userId) return true
const member = spacesRepository.getMember(space.id, userId)
return member?.role === 'owner'
}
export const spacesRoutes: FastifyPluginAsync = async (app) => { export const spacesRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/spaces', async (req) => { app.get<{ Querystring: { deleted?: string } }>('/api/spaces', async (req) => {
const user = req.authUser const user = req.authUser
const deletedOnly = req.query.deleted === '1' || req.query.deleted === 'true'
if (!user) { if (!user) {
if (deletedOnly) {
return spacesRepository.listDeleted().map((s) => ({ ...s, role: 'owner' }))
}
return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' })) return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' }))
} }
return spacesRepository.listForUser(user.id, isSpacesAdmin(req)) return spacesRepository.listForUser(user.id, isSpacesAdmin(req), { deletedOnly })
}) })
app.post('/api/spaces', async (req, reply) => { app.post('/api/spaces', async (req, reply) => {
@@ -65,8 +81,114 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
return { ...space, role: member?.role ?? 'viewer' } return { ...space, role: member?.role ?? 'viewer' }
}) })
/** Soft-delete → корзина */
app.delete<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
const space = spacesRepository.getAny(req.params.id)
if (!space) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
if (space.kind === 'main' || space.id === MAIN_SPACE_ID) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' },
})
}
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Только владелец' } })
}
const updated = spacesRepository.softDelete(req.params.id)
if (!updated) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Не удалось удалить' },
})
}
return updated
})
app.post<{ Params: { id: string } }>('/api/spaces/:id/restore', async (req, reply) => {
const space = spacesRepository.getAny(req.params.id)
if (!space) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
if (!space.deletedAt) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Пространство не в корзине' },
})
}
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
}
return spacesRepository.restore(req.params.id)
})
/** Hard purge — only soft-deleted */
app.delete<{ Params: { id: string } }>('/api/spaces/:id/purge', async (req, reply) => {
const space = spacesRepository.getAny(req.params.id)
if (!space) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
if (space.kind === 'main' || space.id === MAIN_SPACE_ID) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Нельзя удалить основное пространство' },
})
}
if (!space.deletedAt) {
return reply.code(400).send({
error: {
code: 'VALIDATION',
message: 'Сначала переместите пространство в корзину',
},
})
}
if (!canManageDeletedSpace(space, req.authUser?.id, isSpacesAdmin(req))) {
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
}
const ok = spacesRepository.purge(req.params.id)
if (!ok) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Не удалось удалить навсегда' },
})
}
return reply.code(204).send()
})
app.post<{ Params: { id: string } }>(
'/api/spaces/:id/transfer-ownership',
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
const admin = isSpacesAdmin(req)
if (user) {
const member = spacesRepository.getMember(space.id, user.id)
if (!admin && member?.role !== 'owner') {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Только владелец может передать владение',
},
})
}
}
const body = req.body as { newOwnerUserId?: string }
const newOwnerUserId = String(body.newOwnerUserId ?? '').trim()
if (!newOwnerUserId) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'newOwnerUserId обязателен' },
})
}
const updated = spacesRepository.transferOwnership(req.params.id, newOwnerUserId)
if (!updated) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: 'Не удалось передать владение' },
})
}
return updated
},
)
app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => { app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
// Temporarily set space for role check
req.spaceId = req.params.id req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return if (!requireSpaceRole(req, reply, 'admin')) return
const body = req.body as { name?: string; slug?: string } const body = req.body as { name?: string; slug?: string }
@@ -159,7 +281,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
}, },
) )
/** Share VPS from current ownership space to target space */
app.post<{ Params: { id: string; vpsId: string } }>( app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/share', '/api/spaces/:id/vps/:vpsId/share',
async (req, reply) => { async (req, reply) => {
@@ -202,7 +323,10 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
req.spaceId = req.params.id req.spaceId = req.params.id
if (!requireSpaceRole(req, reply, 'admin')) return if (!requireSpaceRole(req, reply, 'admin')) return
const grant = vpsGrantsRepository.get(req.params.grantId) const grant = vpsGrantsRepository.get(req.params.grantId)
if (!grant || (grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)) { if (
!grant ||
(grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)
) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
} }
vpsGrantsRepository.delete(req.params.grantId) vpsGrantsRepository.delete(req.params.grantId)
@@ -210,7 +334,6 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
}, },
) )
/** Assign (move) VPS to target space */
app.post<{ Params: { id: string; vpsId: string } }>( app.post<{ Params: { id: string; vpsId: string } }>(
'/api/spaces/:id/vps/:vpsId/assign', '/api/spaces/:id/vps/:vpsId/assign',
async (req, reply) => { async (req, reply) => {
@@ -235,8 +358,7 @@ export const spacesRoutes: FastifyPluginAsync = async (app) => {
}) })
} }
vpsGrantsRepository.deleteByVps(req.params.vpsId) vpsGrantsRepository.deleteByVps(req.params.vpsId)
const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId) return vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
return moved
}, },
) )
+130
View File
@@ -0,0 +1,130 @@
import { getDb, getSqlite, schema } from '@cfdm/db'
import {
spacesRepository,
vpsGrantsRepository,
} from '@cfdm/db/repositories/spaces'
const TABLE_ORDER_DELETE = [
'vps_grants',
'notification_log',
'notification_state',
'vps_health_checks',
'audit_log',
'sync_log',
'active_tariffs',
'tariff_sync_options',
'topology_diagrams',
'vps_domains',
'balance_ledger',
'payments',
'vps',
'provider_accounts',
'server_projects',
'providers',
'settings',
'space_members',
'spaces',
] as const
const TABLE_ORDER_INSERT = [...TABLE_ORDER_DELETE].reverse()
export const FULL_BACKUP_VERSION = 2
export type FullBackupPayload = {
backupVersion?: number
exportedAt?: string
tables?: Record<string, Record<string, unknown>[]>
/** Legacy v1 fields — ignored when tables present */
[key: string]: unknown
}
function selectAll(table: string): Record<string, unknown>[] {
const sqlite = getSqlite()
try {
return sqlite.prepare(`SELECT * FROM ${table}`).all() as Record<string, unknown>[]
} catch {
return []
}
}
/** Full multi-space dump for backup export. */
export function getFullBackupSnapshot(): FullBackupPayload {
const tables: Record<string, Record<string, unknown>[]> = {}
for (const table of TABLE_ORDER_INSERT) {
tables[table] = selectAll(table)
}
return {
backupVersion: FULL_BACKUP_VERSION,
exportedAt: new Date().toISOString(),
tables,
}
}
function quoteIdent(name: string): string {
return `"${name.replace(/"/g, '""')}"`
}
function insertRows(table: string, rows: Record<string, unknown>[]): void {
if (!rows.length) return
const sqlite = getSqlite()
const cols = Object.keys(rows[0]!)
const placeholders = cols.map(() => '?').join(', ')
const colList = cols.map(quoteIdent).join(', ')
const stmt = sqlite.prepare(
`INSERT INTO ${table} (${colList}) VALUES (${placeholders})`,
)
for (const row of rows) {
stmt.run(...cols.map((c) => row[c] ?? null))
}
}
/**
* Full overwrite import (v2 tables dump).
* Falls back to legacy importJsonSnapshot when `tables` missing.
*/
export function importFullBackup(data: FullBackupPayload): void {
const tables = data.tables
if (!tables || typeof tables !== 'object') {
throw new Error('FULL_BACKUP_REQUIRED')
}
const sqlite = getSqlite()
sqlite.exec('BEGIN')
try {
sqlite.pragma('foreign_keys = OFF')
for (const table of TABLE_ORDER_DELETE) {
try {
sqlite.exec(`DELETE FROM ${table}`)
} catch {
/* missing table */
}
}
for (const table of TABLE_ORDER_INSERT) {
const rows = Array.isArray(tables[table]) ? tables[table]! : []
insertRows(table, rows)
}
sqlite.pragma('foreign_keys = ON')
sqlite.exec('COMMIT')
} catch (err) {
sqlite.exec('ROLLBACK')
sqlite.pragma('foreign_keys = ON')
throw err
}
// Ensure main space exists after import
spacesRepository.getMain()
}
export function listAllSpaceMembers(): typeof schema.spaceMembers.$inferSelect[] {
return getDb().select().from(schema.spaceMembers).all()
}
export function listAllSpaces() {
return spacesRepository.listAll({ includeDeleted: true })
}
export function listAllGrants() {
return vpsGrantsRepository.listAll()
}
+26 -3
View File
@@ -1,3 +1,5 @@
import { useState, type ReactElement, type ReactNode } from 'react'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -9,7 +11,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@cfdm/ui/components/alert-dialog' } from '@cfdm/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps { interface ConfirmDialogProps {
trigger: ReactElement trigger: ReactElement
@@ -18,7 +19,10 @@ interface ConfirmDialogProps {
confirmLabel?: string confirmLabel?: string
cancelLabel?: string cancelLabel?: string
destructive?: boolean destructive?: boolean
/** Called after dialog closes (next tick) — safe for file pickers */
onConfirm: () => void onConfirm: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
} }
export function ConfirmDialog({ export function ConfirmDialog({
@@ -29,9 +33,28 @@ export function ConfirmDialog({
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
destructive, destructive,
onConfirm, onConfirm,
open: openProp,
onOpenChange: onOpenChangeProp,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const isControlled = openProp !== undefined
const open = isControlled ? openProp : uncontrolledOpen
function setOpen(next: boolean) {
if (!isControlled) setUncontrolledOpen(next)
onOpenChangeProp?.(next)
}
function handleConfirm() {
setOpen(false)
// Close AlertDialog before opening native file picker / async work
queueMicrotask(() => {
onConfirm()
})
}
return ( return (
<AlertDialog> <AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={trigger} /> <AlertDialogTrigger render={trigger} />
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -42,7 +65,7 @@ export function ConfirmDialog({
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant={destructive ? 'destructive' : 'default'} variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm} onClick={handleConfirm}
> >
{confirmLabel} {confirmLabel}
</AlertDialogAction> </AlertDialogAction>
@@ -1,5 +1,11 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { CheckIcon, ChevronsUpDownIcon, PlusIcon, UsersIcon } from 'lucide-react' import {
CheckIcon,
ChevronsUpDownIcon,
PlusIcon,
Trash2Icon,
UsersIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -29,10 +35,13 @@ import {
useSidebar, useSidebar,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { useSpaceId, type SpaceDto } from '@/lib/space' import { useSpaceId, type SpaceDto } from '@/lib/space'
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot' import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
const MAIN_KIND = 'main'
export function SpaceSwitcher() { export function SpaceSwitcher() {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const qc = useQueryClient() const qc = useQueryClient()
@@ -49,6 +58,14 @@ export function SpaceSwitcher() {
} }
}, [spaceId, spaces, setSpaceId]) }, [spaceId, spaces, setSpaceId])
// If current space disappeared (soft-deleted), fall back to first active
useEffect(() => {
if (spaceId && spaces.length > 0 && !spaces.some((s) => s.id === spaceId)) {
const next = spaces.find((s) => s.kind === MAIN_KIND) ?? spaces[0]
if (next) setSpaceId(next.id)
}
}, [spaceId, spaces, setSpaceId])
function selectSpace(space: SpaceDto) { function selectSpace(space: SpaceDto) {
if (space.id === currentId) return if (space.id === currentId) return
setSpaceId(space.id) setSpaceId(space.id)
@@ -77,9 +94,27 @@ export function SpaceSwitcher() {
} }
} }
async function handleSoftDelete(space: SpaceDto) {
try {
await api.softDeleteSpace(space.id)
if (spaceId === space.id) {
const next =
spaces.find((s) => s.id !== space.id && s.kind === MAIN_KIND) ??
spaces.find((s) => s.id !== space.id)
if (next) setSpaceId(next.id)
}
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
toast.success('Пространство перемещено в корзину')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Не удалось удалить')
}
}
if (spaces.length === 0) { if (spaces.length === 0) {
return ( return (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Пространства</div> <div className="text-muted-foreground px-2 py-1.5 text-xs">Пространства</div>
) )
} }
@@ -97,7 +132,7 @@ export function SpaceSwitcher() {
} }
> >
<div <div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-muted text-muted-foreground" className="bg-muted text-muted-foreground flex aspect-square size-8 items-center justify-center rounded-md"
aria-hidden aria-hidden
> >
<UsersIcon className="size-4" /> <UsersIcon className="size-4" />
@@ -106,7 +141,7 @@ export function SpaceSwitcher() {
<span className="truncate font-semibold"> <span className="truncate font-semibold">
{current?.name ?? 'Пространство'} {current?.name ?? 'Пространство'}
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="text-muted-foreground truncate text-xs">
{current?.kind === 'main' ? 'Основное' : 'Личное'} {current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''} {current?.role ? ` · ${current.role}` : ''}
</span> </span>
@@ -122,10 +157,7 @@ export function SpaceSwitcher() {
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel>Пространства</DropdownMenuLabel> <DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => ( {spaces.map((s) => (
<DropdownMenuItem <DropdownMenuItem key={s.id} onClick={() => selectSpace(s)}>
key={s.id}
onClick={() => selectSpace(s)}
>
<UsersIcon className="size-4" /> <UsersIcon className="size-4" />
<span className="truncate">{s.name}</span> <span className="truncate">{s.name}</span>
{s.id === current?.id ? ( {s.id === current?.id ? (
@@ -140,6 +172,24 @@ export function SpaceSwitcher() {
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Создать пространство Создать пространство
</DropdownMenuItem> </DropdownMenuItem>
{current && current.kind !== MAIN_KIND ? (
<ConfirmDialog
title="В корзину?"
description="Пространство скроется из списка. Данные сохранятся — можно восстановить на странице «Пространство»."
confirmLabel="В корзину"
destructive
onConfirm={() => void handleSoftDelete(current)}
trigger={
<DropdownMenuItem
onClick={(e) => e.preventDefault()}
className="text-destructive"
>
<Trash2Icon className="size-4" />
Удалить «{current.name}»
</DropdownMenuItem>
}
/>
) : null}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -34,8 +34,10 @@ export function SettingsCard({
}: SettingsCardProps) { }: SettingsCardProps) {
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0">
<FrameHeader className={cn('gap-0 border-b px-5 py-3', headerClassName)}> <FrameHeader
className={cn('gap-0.5 border-b border-border/50 px-5 py-3', headerClassName)}
>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null} {description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader> </FrameHeader>
@@ -44,7 +46,10 @@ export function SettingsCard({
{footer ? ( {footer ? (
<FrameFooter <FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)} className={cn(
'justify-end gap-2 border-t border-border/50 px-5 py-3',
footerClassName,
)}
> >
{footer} {footer}
</FrameFooter> </FrameFooter>
+43 -41
View File
@@ -6,7 +6,6 @@ import {
FieldContent, FieldContent,
FieldDescription, FieldDescription,
FieldLabel, FieldLabel,
FieldSeparator,
FieldTitle, FieldTitle,
} from '@cfdm/ui/components/field' } from '@cfdm/ui/components/field'
@@ -23,7 +22,10 @@ export interface SettingRowProps {
titleAddon?: ReactNode titleAddon?: ReactNode
} }
/** Compact settings row — preview https://reui.io/preview/base/settings-3 */ /**
* Compact settings row — preview https://reui.io/preview/base/settings-3 · settings-2
* Hairline divider via border-b (no FieldSeparator h-5).
*/
export function SettingRow({ export function SettingRow({
title, title,
description, description,
@@ -37,49 +39,49 @@ export function SettingRow({
titleAddon, titleAddon,
}: SettingRowProps) { }: SettingRowProps) {
return ( return (
<> <Field
<Field orientation={stacked ? 'vertical' : 'responsive'}
orientation={stacked ? 'vertical' : 'responsive'} className={cn(
className={cn('gap-4 px-5 py-4', className)} 'gap-4 px-5 py-3.5',
> !last && 'border-border/40 border-b',
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> className,
<div className="flex flex-wrap items-center gap-2"> )}
{labelFor ? ( >
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> <div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
) : ( <div className="flex flex-wrap items-center gap-2">
<FieldTitle>{title}</FieldTitle> {labelFor ? (
)} <FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
{titleAddon} ) : (
</div> <FieldTitle>{title}</FieldTitle>
)}
{description ? ( {titleAddon}
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div> </div>
<FieldContent {description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn( className={cn(
'w-full min-w-0 @md/field-group:flex-1', 'flex w-full justify-start',
stacked stacked ? 'justify-start' : '@md/field-group:justify-end',
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)} )}
> >
<div {children}
className={cn( </div>
'flex w-full justify-start', </FieldContent>
stacked ? 'justify-start' : '@md/field-group:justify-end', </Field>
)}
>
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
) )
} }
+35 -1
View File
@@ -206,7 +206,10 @@ export const api = {
return res.blob() return res.blob()
}, },
fetchSpaces: () => fetchApi<import('@/lib/space').SpaceDto[]>('/api/spaces'), fetchSpaces: (opts?: { deleted?: boolean }) =>
fetchApi<import('@/lib/space').SpaceDto[]>(
opts?.deleted ? '/api/spaces?deleted=1' : '/api/spaces',
),
createSpace: (body: { name: string; slug?: string }) => createSpace: (body: { name: string; slug?: string }) =>
fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', { fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', {
@@ -214,6 +217,37 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
softDeleteSpace: (spaceId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}`,
{ method: 'DELETE' },
),
restoreSpace: (spaceId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}/restore`,
{ method: 'POST', body: '{}' },
),
purgeSpace: (spaceId: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/purge`, {
method: 'DELETE',
}),
transferSpaceOwnership: (spaceId: string, newOwnerUserId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}/transfer-ownership`,
{
method: 'POST',
body: JSON.stringify({ newOwnerUserId }),
},
),
searchPortalUsers: (q: string) =>
fetchApi<{ id: string; email: string; name: string }[]>(
`/api/portal-users?q=${encodeURIComponent(q)}`,
),
fetchSpaceMembers: (spaceId: string) => fetchSpaceMembers: (spaceId: string) =>
fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>( fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>(
`/api/spaces/${encodeURIComponent(spaceId)}/members`, `/api/spaces/${encodeURIComponent(spaceId)}/members`,
+1
View File
@@ -18,6 +18,7 @@ export type SpaceDto = {
kind: string kind: string
ownerUserId: string | null ownerUserId: string | null
createdAt: string createdAt: string
deletedAt?: string | null
role?: string role?: string
} }
+7
View File
@@ -19,6 +19,7 @@ export const snapshotQueryOptions = (spaceId?: string | null) => {
export const spacesKeys = { export const spacesKeys = {
all: ['spaces'] as const, all: ['spaces'] as const,
deleted: ['spaces', 'deleted'] as const,
members: (spaceId: string) => ['spaces', spaceId, 'members'] as const, members: (spaceId: string) => ['spaces', spaceId, 'members'] as const,
} }
@@ -28,6 +29,12 @@ export const spacesQueryOptions = () => ({
staleTime: 60_000, staleTime: 60_000,
}) })
export const deletedSpacesQueryOptions = () => ({
queryKey: spacesKeys.deleted,
queryFn: () => api.fetchSpaces({ deleted: true }),
staleTime: 30_000,
})
export const ratesKeys = { export const ratesKeys = {
all: ['rates'] as const, all: ['rates'] as const,
} }
+16 -5
View File
@@ -5,8 +5,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react' import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions, spacesKeys } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client' import { api, ApiError } from '@/lib/api-client'
import { useSpaceId } from '@/lib/space'
const MAIN_SPACE_ID = 'space-main'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -47,6 +50,7 @@ function SettingsSkeleton() {
function SettingsGeneralPage() { function SettingsGeneralPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { setSpaceId } = useSpaceId()
const { data: snapshot, current, isLoading, isError, error, refetch } = const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot() useSettingsSnapshot()
const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' }) const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' })
@@ -59,10 +63,17 @@ function SettingsGeneralPage() {
values: formValues, values: formValues,
}) })
async function afterBackupImport() {
setSpaceId(MAIN_SPACE_ID)
await queryClient.invalidateQueries({ queryKey: spacesKeys.all })
await queryClient.invalidateQueries({ queryKey: spacesKeys.deleted })
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
}
const importJsonMut = useMutation({ const importJsonMut = useMutation({
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)), mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
onSuccess: () => { onSuccess: async () => {
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) await afterBackupImport()
toast.success('Импорт JSON выполнен') toast.success('Импорт JSON выполнен')
}, },
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'), onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
@@ -70,8 +81,8 @@ function SettingsGeneralPage() {
const importDbMut = useMutation({ const importDbMut = useMutation({
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer), mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
onSuccess: () => { onSuccess: async () => {
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) await afterBackupImport()
toast.success('Импорт SQLite выполнен') toast.success('Импорт SQLite выполнен')
}, },
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'), onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
+310 -63
View File
@@ -1,10 +1,9 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Label } from '@cfdm/ui/components/label' import { Label } from '@cfdm/ui/components/label'
import { import {
Select, Select,
@@ -22,12 +21,20 @@ import {
TableRow, TableRow,
} from '@cfdm/ui/components/table' } from '@cfdm/ui/components/table'
import { AutoCompleteInput } from '@/components/auto-complete-input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { useSpaceId } from '@/lib/space' import { useSpaceId } from '@/lib/space'
import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot' import {
deletedSpacesQueryOptions,
spacesKeys,
spacesQueryOptions,
snapshotKeys,
} from '@/queries/snapshot'
export const Route = createFileRoute('/_auth/spaces')({ export const Route = createFileRoute('/_auth/spaces')({
component: SpacesPage, component: SpacesPage,
@@ -40,12 +47,17 @@ type MemberRow = {
createdAt: string createdAt: string
} }
type PortalUser = { id: string; email: string; name: string }
function SpacesPage() { function SpacesPage() {
const qc = useQueryClient() const qc = useQueryClient()
const { spaceId } = useSpaceId() const { spaceId } = useSpaceId()
const { data: spaces = [] } = useQuery(spacesQueryOptions()) const { data: spaces = [] } = useQuery(spacesQueryOptions())
const { data: trash = [] } = useQuery(deletedSpacesQueryOptions())
const current = spaces.find((s) => s.id === spaceId) ?? spaces[0] const current = spaces.find((s) => s.id === spaceId) ?? spaces[0]
const currentId = current?.id ?? '' const currentId = current?.id ?? ''
const myRole = current?.role ?? 'viewer'
const canAdmin = myRole === 'owner' || myRole === 'admin'
const membersQuery = useQuery({ const membersQuery = useQuery({
queryKey: spacesKeys.members(currentId), queryKey: spacesKeys.members(currentId),
@@ -53,14 +65,75 @@ function SpacesPage() {
enabled: Boolean(currentId), enabled: Boolean(currentId),
}) })
const [userId, setUserId] = useState('') const [userQuery, setUserQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [role, setRole] = useState('member') const [role, setRole] = useState('member')
const [portalUsers, setPortalUsers] = useState<PortalUser[]>([])
const [memberLabels, setMemberLabels] = useState<Record<string, string>>({})
const [transferUserId, setTransferUserId] = useState('')
const [transferQuery, setTransferQuery] = useState('')
useEffect(() => {
const q = userQuery.trim() || transferQuery.trim()
const t = setTimeout(() => {
void api
.searchPortalUsers(q)
.then(setPortalUsers)
.catch(() => setPortalUsers([]))
}, q ? 250 : 0)
return () => clearTimeout(t)
}, [userQuery, transferQuery])
// Resolve names for current members (search by userId)
useEffect(() => {
const members = membersQuery.data
if (!members?.length) return
let cancelled = false
void (async () => {
const next: Record<string, string> = {}
await Promise.all(
members.map(async (m) => {
try {
const found = await api.searchPortalUsers(m.userId)
const u = found.find((x) => x.id === m.userId) ?? found[0]
if (u && u.id === m.userId) {
next[m.userId] = `${u.name} · ${u.email}`
}
} catch {
/* ignore */
}
}),
)
if (!cancelled) setMemberLabels((prev) => ({ ...prev, ...next }))
})()
return () => {
cancelled = true
}
}, [membersQuery.data])
const userOptions = useMemo(
() =>
portalUsers.map((u) => ({
value: u.id,
label: `${u.name} · ${u.email}`,
})),
[portalUsers],
)
const userLabelById = useMemo(() => {
const map = new Map<string, string>(Object.entries(memberLabels))
for (const u of portalUsers) {
map.set(u.id, `${u.name} · ${u.email}`)
}
return map
}, [portalUsers, memberLabels])
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: () => mutationFn: () =>
api.addSpaceMember(currentId, { userId: userId.trim(), role }), api.addSpaceMember(currentId, { userId: selectedUserId.trim(), role }),
onSuccess: async () => { onSuccess: async () => {
setUserId('') setSelectedUserId('')
setUserQuery('')
toast.success('Участник добавлен') toast.success('Участник добавлен')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) }) await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
}, },
@@ -70,12 +143,44 @@ function SpacesPage() {
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid), mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid),
onSuccess: async () => { onSuccess: async () => {
toast.success('Участник удалён') toast.success('Доступ отозван')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) }) await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}) })
const transferMutation = useMutation({
mutationFn: () => api.transferSpaceOwnership(currentId, transferUserId.trim()),
onSuccess: async () => {
setTransferUserId('')
setTransferQuery('')
toast.success('Владение передано')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
const restoreMutation = useMutation({
mutationFn: (id: string) => api.restoreSpace(id),
onSuccess: async () => {
toast.success('Пространство восстановлено')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
},
onError: (e: Error) => toast.error(e.message),
})
const purgeMutation = useMutation({
mutationFn: (id: string) => api.purgeSpace(id),
onSuccess: async () => {
toast.success('Пространство удалено навсегда')
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
@@ -87,80 +192,222 @@ function SpacesPage() {
} }
/> />
<QueryState <div className="flex flex-col gap-4">
data={membersQuery.data as MemberRow[] | undefined} {myRole === 'owner' ? (
isLoading={membersQuery.isLoading} <SettingsCard
isError={membersQuery.isError} title="Владелец"
error={membersQuery.error} description="Передать владение другому пользователю из auth-portal"
onRetry={() => void membersQuery.refetch()} footer={
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)} <ConfirmDialog
emptyTitle="Нет участников" title="Передать владение?"
emptyDescription="Добавьте userId из auth-portal" description="Вы станете admin. Новый владелец получит полный контроль."
> confirmLabel="Передать"
{(members) => ( onConfirm={() => transferMutation.mutate()}
<div className="flex flex-col gap-6"> trigger={
<div className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-end"> <Button
<div className="flex flex-1 flex-col gap-2"> type="button"
<Label htmlFor="member-user-id">User ID (из auth-portal)</Label> disabled={!transferUserId.trim() || transferMutation.isPending}
<Input >
id="member-user-id" Передать владение
value={userId} </Button>
onChange={(e) => setUserId(e.target.value)} }
placeholder="uuid пользователя" />
/> }
</div> >
<div className="flex w-full flex-col gap-2 md:w-40"> <div className="flex flex-col gap-2 px-5 py-4">
<Label>Роль</Label> <Label>Новый владелец</Label>
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}> <AutoCompleteInput
<SelectTrigger> value={
<SelectValue /> transferUserId
</SelectTrigger> ? (userLabelById.get(transferUserId) ?? transferQuery)
<SelectContent> : transferQuery
<SelectItem value="admin">admin</SelectItem> }
<SelectItem value="member">member</SelectItem> onChange={(v) => {
<SelectItem value="viewer">viewer</SelectItem> const match = userOptions.find(
</SelectContent> (o) => o.value === v || o.label === v,
</Select> )
</div> if (match) {
<Button setTransferUserId(match.value)
disabled={!userId.trim() || addMutation.isPending} setTransferQuery(match.label)
onClick={() => addMutation.mutate()} } else {
> setTransferUserId('')
Добавить setTransferQuery(v)
</Button> }
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</div> </div>
</SettingsCard>
) : null}
<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="Добавьте пользователя по имени или email"
>
{(members) => (
<SettingsCard title="Участники" description="Доступ к текущему пространству">
{canAdmin ? (
<div className="flex flex-col gap-3 border-b border-border/40 px-5 py-4 md:flex-row md:items-end">
<div className="flex flex-1 flex-col gap-2">
<Label>Пользователь</Label>
<AutoCompleteInput
value={
selectedUserId
? (userLabelById.get(selectedUserId) ?? userQuery)
: userQuery
}
onChange={(v) => {
const match = userOptions.find(
(o) => o.value === v || o.label === v,
)
if (match) {
setSelectedUserId(match.value)
setUserQuery(match.label)
} else {
setSelectedUserId('')
setUserQuery(v)
}
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</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={!selectedUserId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
) : null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-36" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={`${m.spaceId}-${m.userId}`}>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{userLabelById.get(m.userId) ?? m.userId}
</span>
{!userLabelById.has(m.userId) ? (
<span className="text-muted-foreground font-mono text-xs">
{m.userId}
</span>
) : null}
</div>
</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
{canAdmin && m.role !== 'owner' ? (
<ConfirmDialog
title="Отозвать доступ?"
description="Пользователь потеряет доступ к этому пространству."
confirmLabel="Отозвать"
destructive
onConfirm={() => removeMutation.mutate(m.userId)}
trigger={
<Button variant="outline" size="sm">
Отозвать
</Button>
}
/>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SettingsCard>
)}
</QueryState>
<SettingsCard
title="Корзина"
description="Удалённые пространства можно восстановить или удалить навсегда"
>
{trash.length === 0 ? (
<p className="text-muted-foreground px-5 py-4 text-sm">Корзина пуста</p>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User ID</TableHead> <TableHead>Название</TableHead>
<TableHead>Роль</TableHead> <TableHead>Удалено</TableHead>
<TableHead className="w-28" /> <TableHead className="w-64" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{members.map((m) => ( {trash.map((s) => (
<TableRow key={`${m.spaceId}-${m.userId}`}> <TableRow key={s.id}>
<TableCell className="font-mono text-xs">{m.userId}</TableCell> <TableCell>{s.name}</TableCell>
<TableCell>{m.role}</TableCell> <TableCell className="text-muted-foreground text-sm">
{s.deletedAt
? new Date(s.deletedAt).toLocaleString('ru-RU')
: '—'}
</TableCell>
<TableCell> <TableCell>
{m.role !== 'owner' ? ( <div className="flex flex-wrap gap-2">
<Button <Button
variant="outline"
size="sm" size="sm"
onClick={() => removeMutation.mutate(m.userId)} variant="outline"
onClick={() => restoreMutation.mutate(s.id)}
disabled={restoreMutation.isPending}
> >
Удалить Восстановить
</Button> </Button>
) : null} <ConfirmDialog
title="Удалить навсегда?"
description="Безвозвратно: все данные пространства будут уничтожены."
confirmLabel="Удалить навсегда"
destructive
onConfirm={() => purgeMutation.mutate(s.id)}
trigger={
<Button size="sm" variant="destructive">
Удалить навсегда
</Button>
}
/>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> )}
)} </SettingsCard>
</QueryState> </div>
</PageShell> </PageShell>
) )
} }
+181 -14
View File
@@ -1,5 +1,5 @@
import { and, asc, eq } from 'drizzle-orm' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm'
import { getDb, schema } from '../index.js' import { getDb, getSqlite, schema } from '../index.js'
import { generateId } from './utils.js' import { generateId } from './utils.js'
import { import {
MAIN_SPACE_ID, MAIN_SPACE_ID,
@@ -22,20 +22,81 @@ const ROLE_RANK: Record<SpaceRole, number> = {
owner: 4, owner: 4,
} }
/** Tables with spaceId column — purge order (children first). */
const SPACE_DATA_TABLES = [
'vps_grants',
'notification_log',
'notification_state',
'vps_health_checks',
'audit_log',
'sync_log',
'active_tariffs',
'tariff_sync_options',
'topology_diagrams',
'vps_domains',
'balance_ledger',
'payments',
'vps',
'provider_accounts',
'server_projects',
'providers',
'settings',
'space_members',
] as const
export function roleAtLeast(role: string, min: SpaceRole): boolean { export function roleAtLeast(role: string, min: SpaceRole): boolean {
return (ROLE_RANK[role as SpaceRole] ?? 0) >= ROLE_RANK[min] return (ROLE_RANK[role as SpaceRole] ?? 0) >= ROLE_RANK[min]
} }
export function isSpaceActive(space: Pick<SpaceRow, 'deletedAt'>): boolean {
return space.deletedAt == null || space.deletedAt === ''
}
function nowIso(): string { function nowIso(): string {
return new Date().toISOString() return new Date().toISOString()
} }
export const spacesRepository = { export const spacesRepository = {
listAll(): SpaceRow[] { listAll(opts?: { includeDeleted?: boolean }): SpaceRow[] {
return getDb().select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all() const db = getDb()
if (opts?.includeDeleted) {
return db.select().from(schema.spaces).orderBy(asc(schema.spaces.name)).all()
}
return db
.select()
.from(schema.spaces)
.where(isNull(schema.spaces.deletedAt))
.orderBy(asc(schema.spaces.name))
.all()
}, },
listForUser(userId: string, isAdmin = false): (SpaceRow & { role: string })[] { listDeleted(): SpaceRow[] {
return getDb()
.select()
.from(schema.spaces)
.where(isNotNull(schema.spaces.deletedAt))
.orderBy(asc(schema.spaces.name))
.all()
},
listForUser(
userId: string,
isAdmin = false,
opts?: { deletedOnly?: boolean },
): (SpaceRow & { role: string })[] {
if (opts?.deletedOnly) {
const deleted = this.listDeleted()
return deleted
.filter((s) => {
if (isAdmin) return true
return Boolean(this.getMember(s.id, userId))
})
.map((s) => {
const m = this.getMember(s.id, userId)
return { ...s, role: m?.role ?? (isAdmin ? 'admin' : 'viewer') }
})
}
if (isAdmin) { if (isAdmin) {
return this.listAll().map((s) => { return this.listAll().map((s) => {
const m = this.getMember(s.id, userId) const m = this.getMember(s.id, userId)
@@ -51,17 +112,25 @@ export const spacesRepository = {
const out: (SpaceRow & { role: string })[] = [] const out: (SpaceRow & { role: string })[] = []
for (const m of members) { for (const m of members) {
const space = this.get(m.spaceId) const space = this.get(m.spaceId)
if (space) out.push({ ...space, role: m.role }) if (space && isSpaceActive(space)) out.push({ ...space, role: m.role })
} }
return out.sort((a, b) => a.name.localeCompare(b.name)) return out.sort((a, b) => a.name.localeCompare(b.name))
}, },
get(id: string): SpaceRow | undefined { /** Includes soft-deleted */
getAny(id: string): SpaceRow | undefined {
return getDb().select().from(schema.spaces).where(eq(schema.spaces.id, id)).get() return getDb().select().from(schema.spaces).where(eq(schema.spaces.id, id)).get()
}, },
/** Active only */
get(id: string): SpaceRow | undefined {
const row = this.getAny(id)
if (!row || !isSpaceActive(row)) return undefined
return row
},
getMain(): SpaceRow { getMain(): SpaceRow {
let row = this.get(MAIN_SPACE_ID) let row = this.getAny(MAIN_SPACE_ID)
if (!row) { if (!row) {
row = this.create({ row = this.create({
id: MAIN_SPACE_ID, id: MAIN_SPACE_ID,
@@ -70,6 +139,10 @@ export const spacesRepository = {
kind: 'main', kind: 'main',
ownerUserId: process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null, ownerUserId: process.env.VPS_MAIN_SPACE_OWNER_USER_ID?.trim() || null,
}) })
} else if (!isSpaceActive(row)) {
// Main must never stay soft-deleted
this.restore(MAIN_SPACE_ID)
row = this.getAny(MAIN_SPACE_ID)!
} }
return row return row
}, },
@@ -92,6 +165,7 @@ export const spacesRepository = {
kind: input.kind ?? 'personal', kind: input.kind ?? 'personal',
ownerUserId: input.ownerUserId ?? null, ownerUserId: input.ownerUserId ?? null,
createdAt, createdAt,
deletedAt: null,
}) })
.run() .run()
@@ -106,7 +180,6 @@ export const spacesRepository = {
.run() .run()
} }
// Seed settings for the space
const settingsId = settingsIdForSpace(id) const settingsId = settingsIdForSpace(id)
const existingSettings = db const existingSettings = db
.select() .select()
@@ -125,15 +198,15 @@ export const spacesRepository = {
.run() .run()
} }
return this.get(id)! return this.getAny(id)!
}, },
update( update(
id: string, id: string,
input: Partial<{ name: string; slug: string; ownerUserId: string | null }>, input: Partial<{ name: string; slug: string; ownerUserId: string | null }>,
): SpaceRow | undefined { ): SpaceRow | undefined {
const existing = this.get(id) const existing = this.getAny(id)
if (!existing) return undefined if (!existing || !isSpaceActive(existing)) return undefined
getDb() getDb()
.update(schema.spaces) .update(schema.spaces)
.set({ .set({
@@ -147,10 +220,98 @@ export const spacesRepository = {
return this.get(id) return this.get(id)
}, },
softDelete(id: string): SpaceRow | undefined {
const existing = this.getAny(id)
if (!existing) return undefined
if (existing.kind === 'main' || id === MAIN_SPACE_ID) return undefined
if (!isSpaceActive(existing)) return existing
getDb()
.update(schema.spaces)
.set({ deletedAt: nowIso() })
.where(eq(schema.spaces.id, id))
.run()
return this.getAny(id)
},
restore(id: string): SpaceRow | undefined {
const existing = this.getAny(id)
if (!existing) return undefined
if (isSpaceActive(existing)) return existing
getDb()
.update(schema.spaces)
.set({ deletedAt: null })
.where(eq(schema.spaces.id, id))
.run()
return this.get(id)
},
/**
* Hard purge — only soft-deleted non-main spaces.
* Cascade deletes all space-scoped rows.
*/
purge(id: string): boolean {
const existing = this.getAny(id)
if (!existing) return false
if (existing.kind === 'main' || id === MAIN_SPACE_ID) return false
if (isSpaceActive(existing)) return false
const sqlite = getSqlite()
sqlite.exec('BEGIN')
try {
// Grants referencing this space (from or to)
sqlite
.prepare(
`DELETE FROM vps_grants WHERE fromSpaceId = ? OR toSpaceId = ?`,
)
.run(id, id)
for (const table of SPACE_DATA_TABLES) {
if (table === 'vps_grants' || table === 'space_members') continue
try {
sqlite.prepare(`DELETE FROM ${table} WHERE spaceId = ?`).run(id)
} catch {
/* table may not exist in older DBs */
}
}
sqlite.prepare(`DELETE FROM space_members WHERE spaceId = ?`).run(id)
sqlite.prepare(`DELETE FROM spaces WHERE id = ?`).run(id)
sqlite.exec('COMMIT')
return true
} catch (err) {
sqlite.exec('ROLLBACK')
throw err
}
},
transferOwnership(
spaceId: string,
newOwnerUserId: string,
): SpaceRow | undefined {
const space = this.get(spaceId)
if (!space) return undefined
const oldOwnerId = space.ownerUserId
if (oldOwnerId && oldOwnerId !== newOwnerUserId) {
this.updateMember(spaceId, oldOwnerId, 'admin')
}
this.addMember(spaceId, newOwnerUserId, 'owner')
getDb()
.update(schema.spaces)
.set({ ownerUserId: newOwnerUserId })
.where(eq(schema.spaces.id, spaceId))
.run()
return this.get(spaceId)
},
ensurePersonalSpace(userId: string, name?: string): SpaceRow { ensurePersonalSpace(userId: string, name?: string): SpaceRow {
const id = `space-user-${userId}` const id = `space-user-${userId}`
const existing = this.get(id) const existing = this.getAny(id)
if (existing) { if (existing) {
if (!isSpaceActive(existing)) {
// Soft-deleted personal — do not recreate same id; fall back to main
return this.getMain()
}
const member = this.getMember(id, userId) const member = this.getMember(id, userId)
if (!member) { if (!member) {
this.addMember(id, userId, 'owner') this.addMember(id, userId, 'owner')
@@ -251,6 +412,8 @@ export const spacesRepository = {
}, },
canAccess(spaceId: string, userId: string, isAdmin = false): boolean { canAccess(spaceId: string, userId: string, isAdmin = false): boolean {
const space = this.get(spaceId)
if (!space) return false
if (isAdmin) return true if (isAdmin) return true
return Boolean(this.getMember(spaceId, userId)) return Boolean(this.getMember(spaceId, userId))
}, },
@@ -261,6 +424,7 @@ export const spacesRepository = {
min: SpaceRole, min: SpaceRole,
isAdmin = false, isAdmin = false,
): SpaceMemberRow | null { ): SpaceMemberRow | null {
if (!this.get(spaceId) && !isAdmin) return null
if (isAdmin) { if (isAdmin) {
return ( return (
this.getMember(spaceId, userId) ?? { this.getMember(spaceId, userId) ?? {
@@ -294,6 +458,10 @@ export const vpsGrantsRepository = {
.all() .all()
}, },
listAll(): VpsGrantRow[] {
return getDb().select().from(schema.vpsGrants).all()
},
get(id: string): VpsGrantRow | undefined { get(id: string): VpsGrantRow | undefined {
return getDb().select().from(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).get() return getDb().select().from(schema.vpsGrants).where(eq(schema.vpsGrants.id, id)).get()
}, },
@@ -359,7 +527,6 @@ export const vpsGrantsRepository = {
return r.changes return r.changes
}, },
/** Effective grant for current space context on a VPS owned elsewhere. */
getGrantInCurrentSpace(vpsId: string): VpsGrantRow | undefined { getGrantInCurrentSpace(vpsId: string): VpsGrantRow | undefined {
return this.getForVpsToSpace(vpsId, getCurrentSpaceId()) return this.getForVpsToSpace(vpsId, getCurrentSpaceId())
}, },
+3 -1
View File
@@ -13,7 +13,8 @@ const CORE_TABLE_MIGRATIONS: string[] = [
slug TEXT NOT NULL, slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal', kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT, ownerUserId TEXT,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL,
deletedAt TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS space_members ( `CREATE TABLE IF NOT EXISTS space_members (
spaceId TEXT NOT NULL REFERENCES spaces(id), spaceId TEXT NOT NULL REFERENCES spaces(id),
@@ -324,6 +325,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE vps_health_checks ADD COLUMN spaceId TEXT`, `ALTER TABLE vps_health_checks ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN spaceId TEXT`, `ALTER TABLE audit_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE audit_log ADD COLUMN actorUserId TEXT`, `ALTER TABLE audit_log ADD COLUMN actorUserId TEXT`,
`ALTER TABLE spaces ADD COLUMN deletedAt TEXT`,
`ALTER TABLE sync_log ADD COLUMN spaceId TEXT`, `ALTER TABLE sync_log ADD COLUMN spaceId TEXT`,
`ALTER TABLE sync_log ADD COLUMN summary TEXT`, `ALTER TABLE sync_log ADD COLUMN summary TEXT`,
`ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`, `ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`,
+2
View File
@@ -8,6 +8,8 @@ export const spaces = sqliteTable('spaces', {
kind: text('kind').notNull().default('personal'), kind: text('kind').notNull().default('personal'),
ownerUserId: text('ownerUserId'), ownerUserId: text('ownerUserId'),
createdAt: text('createdAt').notNull(), createdAt: text('createdAt').notNull(),
/** Soft-delete (корзина); null = active */
deletedAt: text('deletedAt'),
}) })
export const spaceMembers = sqliteTable( export const spaceMembers = sqliteTable(
+2 -1
View File
@@ -8,7 +8,8 @@ CREATE TABLE IF NOT EXISTS spaces (
slug TEXT NOT NULL, slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'personal', kind TEXT NOT NULL DEFAULT 'personal',
ownerUserId TEXT, ownerUserId TEXT,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL,
deletedAt TEXT
); );
CREATE TABLE IF NOT EXISTS space_members ( CREATE TABLE IF NOT EXISTS space_members (