feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
RefreshCwIcon,
|
||||
FolderKanbanIcon,
|
||||
HistoryIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SpaceSwitcher } from '@/components/layout/space-switcher'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
|
||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||
@@ -101,6 +103,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/spaces', label: 'Пространство', icon: UsersIcon },
|
||||
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
|
||||
{ to: '/audit', label: 'Журнал изменений', icon: HistoryIcon },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
@@ -127,6 +130,7 @@ const PARENT_ROUTE: Record<string, string> = {
|
||||
'/renewals': '/dashboard',
|
||||
'/sync-journal': '/settings',
|
||||
'/audit': '/settings',
|
||||
'/spaces': '/settings',
|
||||
}
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
@@ -169,6 +173,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
<SpaceSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{navGroups.map((group) => (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@cfdm/ui/components/dialog'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { getStoredSpaceId, setStoredSpaceId, type SpaceDto } from '@/lib/space'
|
||||
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
||||
|
||||
export function SpaceSwitcher() {
|
||||
const qc = useQueryClient()
|
||||
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
||||
const currentId = getStoredSpaceId() ?? spaces[0]?.id
|
||||
const current = spaces.find((s) => s.id === currentId) ?? spaces[0]
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!getStoredSpaceId() && spaces[0]?.id) {
|
||||
setStoredSpaceId(spaces[0].id)
|
||||
}
|
||||
}, [spaces])
|
||||
|
||||
function selectSpace(space: SpaceDto) {
|
||||
setStoredSpaceId(space.id)
|
||||
void qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
void qc.invalidateQueries({ queryKey: spacesKeys.all })
|
||||
toast.success(`Пространство: ${space.name}`)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const n = name.trim()
|
||||
if (!n) return
|
||||
try {
|
||||
const created = await api.createSpace({ name: n })
|
||||
setStoredSpaceId(created.id)
|
||||
setCreateOpen(false)
|
||||
setName('')
|
||||
await qc.invalidateQueries({ queryKey: spacesKeys.all })
|
||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
toast.success('Пространство создано')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Ошибка создания')
|
||||
}
|
||||
}
|
||||
|
||||
if (spaces.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">Пространства…</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />}
|
||||
>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{current?.name ?? 'Пространство'}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current?.kind === 'main' ? 'Основное' : 'Личное'}
|
||||
{current?.role ? ` · ${current.role}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-56 rounded-lg" align="start" sideOffset={4}>
|
||||
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
|
||||
{spaces.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s.id}
|
||||
onClick={() => selectSpace(s)}
|
||||
className={s.id === current?.id ? 'bg-accent' : undefined}
|
||||
>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Создать пространство
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новое пространство</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="space-name">Название</Label>
|
||||
<Input
|
||||
id="space-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Моя команда"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={() => void handleCreate()}>Создать</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { getStoredSpaceId } from '@/lib/space'
|
||||
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
type Props = {
|
||||
vps: Vps | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
||||
const fromSpaceId = getStoredSpaceId() ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
|
||||
const targets = spaces.filter((s) => s.id !== fromSpaceId)
|
||||
const [toSpaceId, setToSpaceId] = useState('')
|
||||
const [permission, setPermission] = useState<'read' | 'write'>('read')
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.shareVps(fromSpaceId, vps!.id, {
|
||||
toSpaceId,
|
||||
permission,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Доступ выдан (share)')
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Сервер перенесён (assign)')
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Доступ к серверу</SheetTitle>
|
||||
<SheetDescription>
|
||||
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
|
||||
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Целевое пространство</Label>
|
||||
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите пространство" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Права (для share)</Label>
|
||||
<Select
|
||||
value={permission}
|
||||
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">read</SelectItem>
|
||||
<SelectItem value="write">write</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
disabled={!toSpaceId || !vps || shareMutation.isPending}
|
||||
onClick={() => shareMutation.mutate()}
|
||||
>
|
||||
Share (ACL)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!toSpaceId || !vps || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
assignMutation.mutate()
|
||||
}}
|
||||
>
|
||||
Assign (перенос)
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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) }),
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
import { api } from '../lib/api-client'
|
||||
import { getStoredSpaceId } from '../lib/space'
|
||||
|
||||
export const snapshotKeys = {
|
||||
all: ['snapshot'] as const,
|
||||
space: (spaceId: string | null) => ['snapshot', spaceId ?? 'default'] as const,
|
||||
}
|
||||
|
||||
export const snapshotQueryOptions = () => ({
|
||||
queryKey: snapshotKeys.all,
|
||||
queryKey: snapshotKeys.space(getStoredSpaceId()),
|
||||
queryFn: () => api.fetchData(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
export const spacesKeys = {
|
||||
all: ['spaces'] as const,
|
||||
members: (spaceId: string) => ['spaces', spaceId, 'members'] as const,
|
||||
}
|
||||
|
||||
export const spacesQueryOptions = () => ({
|
||||
queryKey: spacesKeys.all,
|
||||
queryFn: () => api.fetchSpaces(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export const ratesKeys = {
|
||||
all: ['rates'] as const,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
|
||||
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
|
||||
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
|
||||
import { Route as AuthSpacesRouteImport } from './routes/_auth/spaces'
|
||||
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
|
||||
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
||||
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
|
||||
@@ -60,6 +61,11 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
|
||||
path: '/sync-journal',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSpacesRoute = AuthSpacesRouteImport.update({
|
||||
id: '/spaces',
|
||||
path: '/spaces',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthResourcesRoute = AuthResourcesRouteImport.update({
|
||||
id: '/resources',
|
||||
path: '/resources',
|
||||
@@ -150,6 +156,7 @@ export interface FileRoutesByFullPath {
|
||||
'/renewals': typeof AuthRenewalsRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/spaces': typeof AuthSpacesRoute
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRouteWithChildren
|
||||
@@ -171,6 +178,7 @@ export interface FileRoutesByTo {
|
||||
'/renewals': typeof AuthRenewalsRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/spaces': typeof AuthSpacesRoute
|
||||
'/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRouteWithChildren
|
||||
@@ -195,6 +203,7 @@ export interface FileRoutesById {
|
||||
'/_auth/renewals': typeof AuthRenewalsRoute
|
||||
'/_auth/reports': typeof AuthReportsRoute
|
||||
'/_auth/resources': typeof AuthResourcesRoute
|
||||
'/_auth/spaces': typeof AuthSpacesRoute
|
||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||
@@ -219,6 +228,7 @@ export interface FileRouteTypes {
|
||||
| '/renewals'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/spaces'
|
||||
| '/sync-journal'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
@@ -240,6 +250,7 @@ export interface FileRouteTypes {
|
||||
| '/renewals'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/spaces'
|
||||
| '/sync-journal'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
@@ -263,6 +274,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/renewals'
|
||||
| '/_auth/reports'
|
||||
| '/_auth/resources'
|
||||
| '/_auth/spaces'
|
||||
| '/_auth/sync-journal'
|
||||
| '/_auth/tariffs'
|
||||
| '/_auth/vps'
|
||||
@@ -323,6 +335,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSyncJournalRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/spaces': {
|
||||
id: '/_auth/spaces'
|
||||
path: '/spaces'
|
||||
fullPath: '/spaces'
|
||||
preLoaderRoute: typeof AuthSpacesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/resources': {
|
||||
id: '/_auth/resources'
|
||||
path: '/resources'
|
||||
@@ -479,6 +498,7 @@ interface AuthRouteChildren {
|
||||
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
||||
AuthReportsRoute: typeof AuthReportsRoute
|
||||
AuthResourcesRoute: typeof AuthResourcesRoute
|
||||
AuthSpacesRoute: typeof AuthSpacesRoute
|
||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
||||
@@ -496,6 +516,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthRenewalsRoute: AuthRenewalsRoute,
|
||||
AuthReportsRoute: AuthReportsRoute,
|
||||
AuthResourcesRoute: AuthResourcesRoute,
|
||||
AuthSpacesRoute: AuthSpacesRoute,
|
||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||
AuthTariffsRoute: AuthTariffsRoute,
|
||||
AuthVpsRoute: AuthVpsRouteWithChildren,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { getStoredSpaceId } from '@/lib/space'
|
||||
import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot'
|
||||
|
||||
export const Route = createFileRoute('/_auth/spaces')({
|
||||
component: SpacesPage,
|
||||
})
|
||||
|
||||
type MemberRow = {
|
||||
spaceId: string
|
||||
userId: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function SpacesPage() {
|
||||
const qc = useQueryClient()
|
||||
const spaceId = getStoredSpaceId()
|
||||
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
||||
const current = spaces.find((s) => s.id === spaceId) ?? spaces[0]
|
||||
const currentId = current?.id ?? ''
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: spacesKeys.members(currentId),
|
||||
queryFn: () => api.fetchSpaceMembers(currentId),
|
||||
enabled: Boolean(currentId),
|
||||
})
|
||||
|
||||
const [userId, setUserId] = useState('')
|
||||
const [role, setRole] = useState('member')
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.addSpaceMember(currentId, { userId: userId.trim(), role }),
|
||||
onSuccess: async () => {
|
||||
setUserId('')
|
||||
toast.success('Участник добавлен')
|
||||
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid),
|
||||
onSuccess: async () => {
|
||||
toast.success('Участник удалён')
|
||||
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Пространство"
|
||||
description={
|
||||
current
|
||||
? `${current.name} (${current.kind === 'main' ? 'основное' : 'личное'})`
|
||||
: 'Участники и доступ'
|
||||
}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={membersQuery.data as MemberRow[] | undefined}
|
||||
isLoading={membersQuery.isLoading}
|
||||
isError={membersQuery.isError}
|
||||
error={membersQuery.error}
|
||||
onRetry={() => void membersQuery.refetch()}
|
||||
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)}
|
||||
emptyTitle="Нет участников"
|
||||
emptyDescription="Добавьте userId из auth-portal"
|
||||
>
|
||||
{(members) => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-end">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="member-user-id">User ID (из auth-portal)</Label>
|
||||
<Input
|
||||
id="member-user-id"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="uuid пользователя"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 md:w-40">
|
||||
<Label>Роль</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="member">member</SelectItem>
|
||||
<SelectItem value="viewer">viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!userId.trim() || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead className="w-28" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((m) => (
|
||||
<TableRow key={`${m.spaceId}-${m.userId}`}>
|
||||
<TableCell className="font-mono text-xs">{m.userId}</TableCell>
|
||||
<TableCell>{m.role}</TableCell>
|
||||
<TableCell>
|
||||
{m.role !== 'owner' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeMutation.mutate(m.userId)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon } from 'lucide-react'
|
||||
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon, Share2Icon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
@@ -32,6 +32,7 @@ import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||
import { ProjectColorDot } from '@/components/project-color-dot'
|
||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
|
||||
import { VpsAccessSheet } from '@/components/vps-access-sheet'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
@@ -66,6 +67,8 @@ function VpsPage() {
|
||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [accessVps, setAccessVps] = useState<Vps | null>(null)
|
||||
const [accessOpen, setAccessOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!health) return
|
||||
@@ -414,9 +417,14 @@ function VpsPage() {
|
||||
header: 'Статус',
|
||||
icon: CircleDotIcon,
|
||||
cell: (v) => (
|
||||
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{v.access === 'shared' ? (
|
||||
<Badge variant="outline">Общий</Badge>
|
||||
) : null}
|
||||
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -490,10 +498,25 @@ function VpsPage() {
|
||||
className: 'w-24 text-right',
|
||||
cell: (v) => (
|
||||
<RowActions
|
||||
onEdit={() => openEdit(v)}
|
||||
onDelete={() => deleteMutation.mutate(v.id)}
|
||||
onEdit={v.access === 'shared' && v.grantPermission !== 'write' ? undefined : () => openEdit(v)}
|
||||
onDelete={v.access === 'shared' ? undefined : () => deleteMutation.mutate(v.id)}
|
||||
deleteTitle="Удалить VPS?"
|
||||
deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`}
|
||||
extra={
|
||||
v.access !== 'shared' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Доступ"
|
||||
onClick={() => {
|
||||
setAccessVps(v)
|
||||
setAccessOpen(true)
|
||||
}}
|
||||
>
|
||||
<Share2Icon />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -661,6 +684,11 @@ function VpsPage() {
|
||||
submitting={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
<VpsAccessSheet
|
||||
vps={accessVps}
|
||||
open={accessOpen}
|
||||
onOpenChange={setAccessOpen}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface Vps {
|
||||
paidUntil?: string
|
||||
notes?: string
|
||||
customData?: string | Record<string, string | number | boolean>
|
||||
access?: 'owned' | 'shared'
|
||||
grantPermission?: 'read' | 'write'
|
||||
spaceId?: string
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
|
||||
Reference in New Issue
Block a user