Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : 'Ошибка импорта'),
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user