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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 15:42:54 +07:00
co-authored by Cursor
parent f26b2c8777
commit e360efb885
47 changed files with 2675 additions and 237 deletions
+64
View File
@@ -9,6 +9,7 @@ import type {
BalanceLedgerRow,
} from '@/types/entities'
import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
import { getStoredSpaceId } from '@/lib/space'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -32,6 +33,10 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const spaceId = getStoredSpaceId()
if (spaceId && !headers.has('X-Space-Id')) {
headers.set('X-Space-Id', spaceId)
}
const res = await fetch(url, {
...options,
headers,
@@ -161,6 +166,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -179,6 +186,8 @@ export const api = {
const headers = new Headers()
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const spaceId = getStoredSpaceId()
if (spaceId) headers.set('X-Space-Id', spaceId)
const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
if (!res.ok) {
if (res.status === 401) {
@@ -193,6 +202,61 @@ export const api = {
return res.blob()
},
fetchSpaces: () => fetchApi<import('@/lib/space').SpaceDto[]>('/api/spaces'),
createSpace: (body: { name: string; slug?: string }) =>
fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', {
method: 'POST',
body: JSON.stringify(body),
}),
fetchSpaceMembers: (spaceId: string) =>
fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>(
`/api/spaces/${encodeURIComponent(spaceId)}/members`,
),
addSpaceMember: (spaceId: string, body: { userId: string; role?: string }) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateSpaceMember: (spaceId: string, userId: string, role: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify({ role }),
}),
removeSpaceMember: (spaceId: string, userId: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
}),
shareVps: (
fromSpaceId: string,
vpsId: string,
body: { toSpaceId: string; permission: 'read' | 'write' },
) =>
fetchApi(`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/share`, {
method: 'POST',
body: JSON.stringify(body),
}),
assignVps: (fromSpaceId: string, vpsId: string, toSpaceId: string) =>
fetchApi(
`/api/spaces/${encodeURIComponent(fromSpaceId)}/vps/${encodeURIComponent(vpsId)}/assign`,
{
method: 'POST',
body: JSON.stringify({ toSpaceId }),
},
),
fetchSpaceGrants: (spaceId: string) =>
fetchApi<{
incoming: unknown[]
outgoing: unknown[]
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`),
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
+5 -1
View File
@@ -199,7 +199,11 @@ export function permissionForPath(pathname: string): string | null {
return 'vps:payments:read'
}
if (pathname.startsWith('/sync-journal')) return 'vps:sync:write'
if (pathname.startsWith('/settings') || pathname.startsWith('/audit')) {
if (
pathname.startsWith('/settings') ||
pathname.startsWith('/audit') ||
pathname.startsWith('/spaces')
) {
return 'vps:settings:admin'
}
return 'vps:dashboard:read'
+35
View File
@@ -0,0 +1,35 @@
const STORAGE_KEY = 'vps_space_id'
export type SpaceDto = {
id: string
name: string
slug: string
kind: string
ownerUserId: string | null
createdAt: string
role?: string
}
export function getStoredSpaceId(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
export function setStoredSpaceId(id: string): void {
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
/* ignore */
}
}
export function clearStoredSpaceId(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}