diff --git a/apps/web/src/components/vps-access-sheet.tsx b/apps/web/src/components/vps-access-sheet.tsx index 1b26ace..e55b3a2 100644 --- a/apps/web/src/components/vps-access-sheet.tsx +++ b/apps/web/src/components/vps-access-sheet.tsx @@ -1,16 +1,8 @@ +import { useEffect, useMemo, useState } from 'react' 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, @@ -19,12 +11,21 @@ import { SheetHeader, SheetTitle, } from '@cfdm/ui/components/sheet' +import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' +import { Badge } from '@cfdm/ui/components/badge' -import { api } from '@/lib/api-client' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { FormField } from '@/components/form-field' +import { LoadingButton } from '@/components/loading-button' +import { SelectField } from '@/components/select-field' +import { api, type SpaceVpsGrant } from '@/lib/api-client' +import { copyText } from '@/lib/clipboard' import { useSpaceId } from '@/lib/space' -import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot' +import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot' import type { Vps } from '@/types/entities' +type AccessMode = 'share' | 'assign' + type Props = { vps: Vps | null open: boolean @@ -36,100 +37,246 @@ export function VpsAccessSheet({ vps, open, onOpenChange }: Props) { const { spaceId } = useSpaceId() const { data: spaces = [] } = useQuery(spacesQueryOptions()) const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? '' - const targets = spaces.filter((s) => s.id !== fromSpaceId) - const [toSpaceId, setToSpaceId] = useState('') + const targets = useMemo( + () => spaces.filter((s) => s.id !== fromSpaceId && !s.deletedAt), + [spaces, fromSpaceId], + ) + const spaceNameById = useMemo( + () => new Map(spaces.map((s) => [s.id, s.name])), + [spaces], + ) + + const [mode, setMode] = useState('share') + const [toSpaceId, setToSpaceId] = useState(null) const [permission, setPermission] = useState<'read' | 'write'>('read') + const [assignConfirmOpen, setAssignConfirmOpen] = useState(false) + + useEffect(() => { + if (!open) return + setMode('share') + setToSpaceId(null) + setPermission('read') + setAssignConfirmOpen(false) + }, [open, vps?.id]) + + const grantsQuery = useQuery({ + queryKey: [...spacesKeys.all, fromSpaceId, 'vps-grants', vps?.id], + queryFn: () => api.fetchSpaceGrants(fromSpaceId), + enabled: open && Boolean(fromSpaceId) && Boolean(vps?.id), + }) + + const outgoingForVps = useMemo(() => { + const outgoing = (grantsQuery.data?.outgoing ?? []) as SpaceVpsGrant[] + if (!vps?.id) return [] + return outgoing.filter((g) => g.vpsId === vps.id) + }, [grantsQuery.data, vps?.id]) + + const spaceOptions = useMemo( + () => targets.map((s) => ({ value: s.id, label: s.name })), + [targets], + ) + + const permissionOptions = useMemo( + () => [ + { value: 'read', label: 'Чтение' }, + { value: 'write', label: 'Запись' }, + ], + [], + ) + + const invalidate = async () => { + await Promise.all([ + qc.invalidateQueries({ queryKey: snapshotKeys.all }), + qc.invalidateQueries({ queryKey: spacesKeys.all }), + ]) + } const shareMutation = useMutation({ mutationFn: () => api.shareVps(fromSpaceId, vps!.id, { - toSpaceId, + toSpaceId: toSpaceId!, permission, }), onSuccess: async () => { - toast.success('Доступ выдан (share)') + toast.success('Доступ выдан') onOpenChange(false) - await qc.invalidateQueries({ queryKey: snapshotKeys.all }) + await invalidate() }, onError: (e: Error) => toast.error(e.message), }) const assignMutation = useMutation({ - mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId), + mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId!), onSuccess: async () => { - toast.success('Сервер перенесён (assign)') + toast.success('Сервер перенесён') onOpenChange(false) - await qc.invalidateQueries({ queryKey: snapshotKeys.all }) + await invalidate() }, onError: (e: Error) => toast.error(e.message), }) + const revokeMutation = useMutation({ + mutationFn: (grantId: string) => api.revokeVpsGrant(fromSpaceId, grantId), + onSuccess: async () => { + toast.success('Доступ отозван') + await invalidate() + await grantsQuery.refetch() + }, + onError: (e: Error) => toast.error(e.message), + }) + + const vpsLabel = vps?.ip || vps?.dns || vps?.id || '' + const canSubmit = Boolean(toSpaceId && vps && fromSpaceId && targets.length > 0) + const busy = shareMutation.isPending || assignMutation.isPending + return ( - - + + Доступ к серверу - - {vps ? `${vps.ip || vps.dns || vps.id}` : ''} - {' — share оставляет запись здесь; assign переносит в другое пространство.'} + + {vps?.ip ? ( + + ) : ( + {vpsLabel} + )} + + {mode === 'share' + ? 'Поделиться — сервер остаётся здесь, в целевом пространстве появится доступ.' + : 'Перенести — сервер уйдёт в другое пространство; привязка к аккаунту провайдера сбросится.'} + -
- - +
+ setMode((v as AccessMode) ?? 'share')} + > + + + Поделиться + + + Перенести + + + + + {targets.length === 0 ? ( +

+ Нет других пространств. Создайте пространство, чтобы поделиться или перенести сервер. +

+ ) : ( +
+ + + + + {mode === 'share' ? ( + + setPermission((v as 'read' | 'write') ?? 'read')} + /> + + ) : null} +
+ )} + +
+

Текущие доступы

+ {grantsQuery.isLoading ? ( +

Загрузка…

+ ) : outgoingForVps.length === 0 ? ( +

Нет выданных доступов для этого сервера

+ ) : ( +
    + {outgoingForVps.map((grant) => ( +
  • +
    + + {spaceNameById.get(grant.toSpaceId) ?? grant.toSpaceId} + + + {grant.permission === 'write' ? 'Запись' : 'Чтение'} + +
    + +
  • + ))} +
+ )} +
-
- - -
- - - - + + {mode === 'share' ? ( + shareMutation.mutate()} + > + Поделиться + + ) : ( + <> + setAssignConfirmOpen(true)} + > + Перенести + + assignMutation.mutate()} + /> + + )} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index ff8ee33..77a84c0 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -30,6 +30,16 @@ export class ApiError extends Error { } } +export type SpaceVpsGrant = { + id: string + vpsId: string + fromSpaceId: string + toSpaceId: string + permission: 'read' | 'write' | string + grantedByUserId?: string | null + createdAt: string +} + async function handoffOnUnauthorized(): Promise { clearToken() const cfg = await ensureAuthConfig() @@ -296,10 +306,16 @@ export const api = { fetchSpaceGrants: (spaceId: string) => fetchApi<{ - incoming: unknown[] - outgoing: unknown[] + incoming: SpaceVpsGrant[] + outgoing: SpaceVpsGrant[] }>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`), + revokeVpsGrant: (spaceId: string, grantId: string) => + fetchApi( + `/api/spaces/${encodeURIComponent(spaceId)}/vps-grants/${encodeURIComponent(grantId)}`, + { method: 'DELETE' }, + ), + importBackupJson: (payload: unknown) => fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }), diff --git a/apps/web/src/lib/clipboard.ts b/apps/web/src/lib/clipboard.ts new file mode 100644 index 0000000..4b030da --- /dev/null +++ b/apps/web/src/lib/clipboard.ts @@ -0,0 +1,19 @@ +import { toast } from 'sonner' + +/** Copy text to clipboard and show a success toast. */ +export async function copyText(value: string, successMessage = 'Скопировано'): Promise { + const text = value.trim() + if (!text) return false + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } else { + return false + } + toast.success(successMessage) + return true + } catch { + toast.error('Не удалось скопировать') + return false + } +} diff --git a/apps/web/src/routes/_auth/projects.$projectId.tsx b/apps/web/src/routes/_auth/projects.$projectId.tsx index 48bfe52..8a3686a 100644 --- a/apps/web/src/routes/_auth/projects.$projectId.tsx +++ b/apps/web/src/routes/_auth/projects.$projectId.tsx @@ -25,8 +25,15 @@ import { StatusBadge } from '@/components/status-badge' import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet' import type { ProjectFormValues } from '@/lib/schemas' import { Button } from '@cfdm/ui/components/button' -import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' import { ConfirmDialog } from '@/components/confirm-dialog' +import { copyText } from '@/lib/clipboard' import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons' import { formatCurrency, @@ -129,15 +136,31 @@ function ProjectDetailPage() { const columns: DataGridColumn[] = [ { key: 'ip', - header: 'IP', + header: 'IP / DNS', cell: (v) => ( - +
+ {v.ip ? ( + + ) : ( + + )} + {v.dns ? ( + + ) : null} +
), }, { @@ -292,14 +315,15 @@ function ProjectDetailPage() { ]} /> {project.notes?.trim() ? ( - - - Заметки - - + + + Заметки + Комментарии к проекту + +

{project.notes}

-
-
+ + ) : null} ( -
+
e.stopPropagation()}>
diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 3f5571b..68fe404 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -33,6 +33,7 @@ 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 { copyText } from '@/lib/clipboard' import type { Vps } from '@/types/entities' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager' @@ -343,12 +344,30 @@ function VpsPage() { header: 'IP / DNS', icon: GlobeIcon, sortValue: (v) => v.ip || v.dns || '', - cell: (v) => dataGridCellStack( - , - v.dns || undefined, - ), + cell: (v) => + dataGridCellStack( + v.ip ? ( + + ) : ( + + ), + v.dns ? ( + + ) : undefined, + ), }, { key: 'domains',