feat(web): клик по проекту, копирование IP и доступ к серверу
Docker / build (push) Failing after 27s

Исправить выбор пространства через SelectField; режимы share/assign и отзыв grant.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-06 22:53:35 +07:00
co-authored by Cursor
parent c14cf3953e
commit 54f3587b45
6 changed files with 349 additions and 115 deletions
+224 -77
View File
@@ -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<AccessMode>('share')
const [toSpaceId, setToSpaceId] = useState<string | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetContent side="right" className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md">
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>Доступ к серверу</SheetTitle>
<SheetDescription>
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
<SheetDescription className="flex flex-col gap-1">
{vps?.ip ? (
<button
type="button"
className="text-foreground w-fit font-medium underline-offset-4 hover:underline"
onClick={() => void copyText(vps.ip, 'IP скопирован')}
>
{vps.ip}
</button>
) : (
<span>{vpsLabel}</span>
)}
<span>
{mode === 'share'
? 'Поделиться — сервер остаётся здесь, в целевом пространстве появится доступ.'
: 'Перенести — сервер уйдёт в другое пространство; привязка к аккаунту провайдера сбросится.'}
</span>
</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 className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<Tabs
value={mode}
onValueChange={(v) => setMode((v as AccessMode) ?? 'share')}
>
<TabsList variant="line" className="gap-5">
<TabsTrigger
value="share"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
Поделиться
</TabsTrigger>
<TabsTrigger
value="assign"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
Перенести
</TabsTrigger>
</TabsList>
</Tabs>
{targets.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет других пространств. Создайте пространство, чтобы поделиться или перенести сервер.
</p>
) : (
<div className="flex flex-col gap-4">
<FormField label="Целевое пространство" htmlFor="access-to-space">
<SelectField
triggerId="access-to-space"
options={spaceOptions}
value={toSpaceId}
onValueChange={setToSpaceId}
placeholder="Выберите пространство"
/>
</FormField>
{mode === 'share' ? (
<FormField
label="Права"
htmlFor="access-permission"
description="Чтение — только просмотр; запись — можно редактировать"
>
<SelectField
triggerId="access-permission"
options={permissionOptions}
value={permission}
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
/>
</FormField>
) : null}
</div>
)}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Текущие доступы</h3>
{grantsQuery.isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : outgoingForVps.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет выданных доступов для этого сервера</p>
) : (
<ul className="flex flex-col gap-2">
{outgoingForVps.map((grant) => (
<li
key={grant.id}
className="border-border/50 flex items-center justify-between gap-2 rounded-lg border px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{spaceNameById.get(grant.toSpaceId) ?? grant.toSpaceId}
</span>
<Badge variant="secondary" className="w-fit">
{grant.permission === 'write' ? 'Запись' : 'Чтение'}
</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate(grant.id)}
>
Отозвать
</Button>
</li>
))}
</ul>
)}
</section>
</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 className="shrink-0 border-t border-border/50 sm:flex-col">
{mode === 'share' ? (
<LoadingButton
type="button"
loading={shareMutation.isPending}
disabled={!canSubmit || busy}
onClick={() => shareMutation.mutate()}
>
Поделиться
</LoadingButton>
) : (
<>
<LoadingButton
type="button"
variant="outline"
loading={assignMutation.isPending}
disabled={!canSubmit || busy}
onClick={() => setAssignConfirmOpen(true)}
>
Перенести
</LoadingButton>
<ConfirmDialog
open={assignConfirmOpen}
onOpenChange={setAssignConfirmOpen}
title="Перенести сервер?"
description="Привязка к аккаунту провайдера будет сброшена. Все выданные доступы (share) для этого сервера будут отозваны."
confirmLabel="Перенести"
destructive
onConfirm={() => assignMutation.mutate()}
/>
</>
)}
</SheetFooter>
</SheetContent>
</Sheet>
+18 -2
View File
@@ -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<void> {
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<void>(
`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants/${encodeURIComponent(grantId)}`,
{ method: 'DELETE' },
),
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
+19
View File
@@ -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<boolean> {
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
}
}
@@ -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<Vps>[] = [
{
key: 'ip',
header: 'IP',
header: 'IP / DNS',
cell: (v) => (
<Button
variant="link"
className="h-auto p-0 font-medium"
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
>
{v.ip || v.id}
</Button>
<div className="flex flex-col gap-0.5">
{v.ip ? (
<Button
type="button"
variant="link"
className="h-auto justify-start p-0 font-medium"
onClick={() => void copyText(v.ip, 'IP скопирован')}
>
{v.ip}
</Button>
) : (
<span className="text-muted-foreground"></span>
)}
{v.dns ? (
<Button
variant="link"
className="text-muted-foreground h-auto justify-start p-0 text-xs font-normal"
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
>
{v.dns}
</Button>
) : null}
</div>
),
},
{
@@ -292,14 +315,15 @@ function ProjectDetailPage() {
]}
/>
{project.notes?.trim() ? (
<Card>
<CardHeader>
<CardTitle>Заметки</CardTitle>
</CardHeader>
<CardContent>
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Заметки</FrameTitle>
<FrameDescription>Комментарии к проекту</FrameDescription>
</FrameHeader>
<FramePanel>
<p className="text-sm whitespace-pre-wrap">{project.notes}</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
) : null}
<ResourcePage
title="VPS проекта"
+23 -14
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import {
@@ -44,6 +44,7 @@ export const Route = createFileRoute('/_auth/projects')({
})
function ProjectsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
@@ -133,7 +134,7 @@ function ProjectsPage() {
header: 'Проект',
icon: FolderKanbanIcon,
cell: (row) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ProjectColorDot color={row.color} />
<Button
variant="link"
@@ -184,18 +185,20 @@ function ProjectsPage() {
sortable: false,
className: 'w-24 text-right',
cell: (row) => (
<RowActions
onEdit={() => openEdit(row)}
onDelete={() => {
if (row.vpsTotal > 0) {
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
return
}
delMut.mutate(row.id)
}}
deleteTitle="Удалить проект?"
deleteDescription={`«${row.name}» будет удалён.`}
/>
<div onClick={(e) => e.stopPropagation()}>
<RowActions
onEdit={() => openEdit(row)}
onDelete={() => {
if (row.vpsTotal > 0) {
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
return
}
delMut.mutate(row.id)
}}
deleteTitle="Удалить проект?"
deleteDescription={`«${row.name}» будет удалён.`}
/>
</div>
),
},
]
@@ -296,6 +299,12 @@ function ProjectsPage() {
data={rows}
getRowId={(r) => r.id}
pinLastColumn
onRowClick={(row) =>
void navigate({
to: '/projects/$projectId',
params: { projectId: row.id },
})
}
/>
)}
</div>
+25 -6
View File
@@ -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(
<Button variant="link" className="h-auto p-0 font-normal" render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}>
{v.ip || '—'}
</Button>,
v.dns || undefined,
),
cell: (v) =>
dataGridCellStack(
v.ip ? (
<Button
type="button"
variant="link"
className="h-auto p-0 font-normal"
onClick={() => void copyText(v.ip, 'IP скопирован')}
>
{v.ip}
</Button>
) : (
<span className="text-muted-foreground"></span>
),
v.dns ? (
<Button
variant="link"
className="text-muted-foreground h-auto p-0 text-xs font-normal"
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
>
{v.dns}
</Button>
) : undefined,
),
},
{
key: 'domains',