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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { 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 { Label } from '@cfdm/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -19,12 +11,21 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@cfdm/ui/components/sheet' } 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 { useSpaceId } from '@/lib/space'
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot' import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
import type { Vps } from '@/types/entities' import type { Vps } from '@/types/entities'
type AccessMode = 'share' | 'assign'
type Props = { type Props = {
vps: Vps | null vps: Vps | null
open: boolean open: boolean
@@ -36,100 +37,246 @@ export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
const { spaceId } = useSpaceId() const { spaceId } = useSpaceId()
const { data: spaces = [] } = useQuery(spacesQueryOptions()) const { data: spaces = [] } = useQuery(spacesQueryOptions())
const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? '' const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
const targets = spaces.filter((s) => s.id !== fromSpaceId) const targets = useMemo(
const [toSpaceId, setToSpaceId] = useState('') () => 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 [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({ const shareMutation = useMutation({
mutationFn: () => mutationFn: () =>
api.shareVps(fromSpaceId, vps!.id, { api.shareVps(fromSpaceId, vps!.id, {
toSpaceId, toSpaceId: toSpaceId!,
permission, permission,
}), }),
onSuccess: async () => { onSuccess: async () => {
toast.success('Доступ выдан (share)') toast.success('Доступ выдан')
onOpenChange(false) onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all }) await invalidate()
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}) })
const assignMutation = useMutation({ const assignMutation = useMutation({
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId), mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId!),
onSuccess: async () => { onSuccess: async () => {
toast.success('Сервер перенесён (assign)') toast.success('Сервер перенесён')
onOpenChange(false) onOpenChange(false)
await qc.invalidateQueries({ queryKey: snapshotKeys.all }) await invalidate()
}, },
onError: (e: Error) => toast.error(e.message), 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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md"> <SheetContent side="right" className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md">
<SheetHeader> <SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>Доступ к серверу</SheetTitle> <SheetTitle>Доступ к серверу</SheetTitle>
<SheetDescription> <SheetDescription className="flex flex-col gap-1">
{vps ? `${vps.ip || vps.dns || vps.id}` : ''} {vps?.ip ? (
{' — share оставляет запись здесь; assign переносит в другое пространство.'} <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> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-2"> <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<Label>Целевое пространство</Label> <Tabs
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}> value={mode}
<SelectTrigger> onValueChange={(v) => setMode((v as AccessMode) ?? 'share')}
<SelectValue placeholder="Выберите пространство" /> >
</SelectTrigger> <TabsList variant="line" className="gap-5">
<SelectContent> <TabsTrigger
{targets.map((s) => ( value="share"
<SelectItem key={s.id} value={s.id}> className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
{s.name} >
</SelectItem> Поделиться
))} </TabsTrigger>
</SelectContent> <TabsTrigger
</Select> 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>
<div className="flex flex-col gap-2"> <SheetFooter className="shrink-0 border-t border-border/50 sm:flex-col">
<Label>Права (для share)</Label> {mode === 'share' ? (
<Select <LoadingButton
value={permission} type="button"
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')} loading={shareMutation.isPending}
> disabled={!canSubmit || busy}
<SelectTrigger> onClick={() => shareMutation.mutate()}
<SelectValue /> >
</SelectTrigger> Поделиться
<SelectContent> </LoadingButton>
<SelectItem value="read">read</SelectItem> ) : (
<SelectItem value="write">write</SelectItem> <>
</SelectContent> <LoadingButton
</Select> type="button"
</div> variant="outline"
loading={assignMutation.isPending}
<SheetFooter className="flex-col gap-2 sm:flex-col"> disabled={!canSubmit || busy}
<Button onClick={() => setAssignConfirmOpen(true)}
disabled={!toSpaceId || !vps || shareMutation.isPending} >
onClick={() => shareMutation.mutate()} Перенести
> </LoadingButton>
Share (ACL) <ConfirmDialog
</Button> open={assignConfirmOpen}
<Button onOpenChange={setAssignConfirmOpen}
variant="outline" title="Перенести сервер?"
disabled={!toSpaceId || !vps || assignMutation.isPending} description="Привязка к аккаунту провайдера будет сброшена. Все выданные доступы (share) для этого сервера будут отозваны."
onClick={() => { confirmLabel="Перенести"
if ( destructive
!window.confirm( onConfirm={() => assignMutation.mutate()}
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.', />
) </>
) { )}
return
}
assignMutation.mutate()
}}
>
Assign (перенос)
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </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> { async function handoffOnUnauthorized(): Promise<void> {
clearToken() clearToken()
const cfg = await ensureAuthConfig() const cfg = await ensureAuthConfig()
@@ -296,10 +306,16 @@ export const api = {
fetchSpaceGrants: (spaceId: string) => fetchSpaceGrants: (spaceId: string) =>
fetchApi<{ fetchApi<{
incoming: unknown[] incoming: SpaceVpsGrant[]
outgoing: unknown[] outgoing: SpaceVpsGrant[]
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`), }>(`/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) => importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }), 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 { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
import type { ProjectFormValues } from '@/lib/schemas' import type { ProjectFormValues } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' 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 { ConfirmDialog } from '@/components/confirm-dialog'
import { copyText } from '@/lib/clipboard'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons' import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { import {
formatCurrency, formatCurrency,
@@ -129,15 +136,31 @@ function ProjectDetailPage() {
const columns: DataGridColumn<Vps>[] = [ const columns: DataGridColumn<Vps>[] = [
{ {
key: 'ip', key: 'ip',
header: 'IP', header: 'IP / DNS',
cell: (v) => ( cell: (v) => (
<Button <div className="flex flex-col gap-0.5">
variant="link" {v.ip ? (
className="h-auto p-0 font-medium" <Button
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />} type="button"
> variant="link"
{v.ip || v.id} className="h-auto justify-start p-0 font-medium"
</Button> 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() ? ( {project.notes?.trim() ? (
<Card> <Frame spacing="sm" className="w-full">
<CardHeader> <FrameHeader>
<CardTitle>Заметки</CardTitle> <FrameTitle>Заметки</FrameTitle>
</CardHeader> <FrameDescription>Комментарии к проекту</FrameDescription>
<CardContent> </FrameHeader>
<FramePanel>
<p className="text-sm whitespace-pre-wrap">{project.notes}</p> <p className="text-sm whitespace-pre-wrap">{project.notes}</p>
</CardContent> </FramePanel>
</Card> </Frame>
) : null} ) : null}
<ResourcePage <ResourcePage
title="VPS проекта" 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { import {
@@ -44,6 +44,7 @@ export const Route = createFileRoute('/_auth/projects')({
}) })
function ProjectsPage() { function ProjectsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0] const settings = snapshot?.settings?.[0]
@@ -133,7 +134,7 @@ function ProjectsPage() {
header: 'Проект', header: 'Проект',
icon: FolderKanbanIcon, icon: FolderKanbanIcon,
cell: (row) => ( cell: (row) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ProjectColorDot color={row.color} /> <ProjectColorDot color={row.color} />
<Button <Button
variant="link" variant="link"
@@ -184,18 +185,20 @@ function ProjectsPage() {
sortable: false, sortable: false,
className: 'w-24 text-right', className: 'w-24 text-right',
cell: (row) => ( cell: (row) => (
<RowActions <div onClick={(e) => e.stopPropagation()}>
onEdit={() => openEdit(row)} <RowActions
onDelete={() => { onEdit={() => openEdit(row)}
if (row.vpsTotal > 0) { onDelete={() => {
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`) if (row.vpsTotal > 0) {
return toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
} return
delMut.mutate(row.id) }
}} delMut.mutate(row.id)
deleteTitle="Удалить проект?" }}
deleteDescription={`«${row.name}» будет удалён.`} deleteTitle="Удалить проект?"
/> deleteDescription={`«${row.name}» будет удалён.`}
/>
</div>
), ),
}, },
] ]
@@ -296,6 +299,12 @@ function ProjectsPage() {
data={rows} data={rows}
getRowId={(r) => r.id} getRowId={(r) => r.id}
pinLastColumn pinLastColumn
onRowClick={(row) =>
void navigate({
to: '/projects/$projectId',
params: { projectId: row.id },
})
}
/> />
)} )}
</div> </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 { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell' import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
import { VpsAccessSheet } from '@/components/vps-access-sheet' import { VpsAccessSheet } from '@/components/vps-access-sheet'
import { copyText } from '@/lib/clipboard'
import type { Vps } from '@/types/entities' import type { Vps } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
@@ -343,12 +344,30 @@ function VpsPage() {
header: 'IP / DNS', header: 'IP / DNS',
icon: GlobeIcon, icon: GlobeIcon,
sortValue: (v) => v.ip || v.dns || '', sortValue: (v) => v.ip || v.dns || '',
cell: (v) => dataGridCellStack( cell: (v) =>
<Button variant="link" className="h-auto p-0 font-normal" render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}> dataGridCellStack(
{v.ip || '—'} v.ip ? (
</Button>, <Button
v.dns || undefined, 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', key: 'domains',