feat(spaces): корзина, hard purge, transfer и полный backup
Docker / build (push) Failing after 20s

Soft-delete/restore/purge пространств, смена владельца, импорт JSON с spaces, proxy portal-users и polish Settings/ConfirmDialog.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 12:50:18 +07:00
co-authored by Cursor
parent 74af90270f
commit 670b5a4468
19 changed files with 1131 additions and 167 deletions
+26 -3
View File
@@ -1,3 +1,5 @@
import { useState, type ReactElement, type ReactNode } from 'react'
import {
AlertDialog,
AlertDialogAction,
@@ -9,7 +11,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@cfdm/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
@@ -18,7 +19,10 @@ interface ConfirmDialogProps {
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
/** Called after dialog closes (next tick) — safe for file pickers */
onConfirm: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function ConfirmDialog({
@@ -29,9 +33,28 @@ export function ConfirmDialog({
cancelLabel = 'Отмена',
destructive,
onConfirm,
open: openProp,
onOpenChange: onOpenChangeProp,
}: ConfirmDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const isControlled = openProp !== undefined
const open = isControlled ? openProp : uncontrolledOpen
function setOpen(next: boolean) {
if (!isControlled) setUncontrolledOpen(next)
onOpenChangeProp?.(next)
}
function handleConfirm() {
setOpen(false)
// Close AlertDialog before opening native file picker / async work
queueMicrotask(() => {
onConfirm()
})
}
return (
<AlertDialog>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -42,7 +65,7 @@ export function ConfirmDialog({
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
onClick={handleConfirm}
>
{confirmLabel}
</AlertDialogAction>
@@ -1,5 +1,11 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { CheckIcon, ChevronsUpDownIcon, PlusIcon, UsersIcon } from 'lucide-react'
import {
CheckIcon,
ChevronsUpDownIcon,
PlusIcon,
Trash2Icon,
UsersIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -29,10 +35,13 @@ import {
useSidebar,
} from '@cfdm/ui/components/sidebar'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { api } from '@/lib/api-client'
import { useSpaceId, type SpaceDto } from '@/lib/space'
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
const MAIN_KIND = 'main'
export function SpaceSwitcher() {
const { isMobile } = useSidebar()
const qc = useQueryClient()
@@ -49,6 +58,14 @@ export function SpaceSwitcher() {
}
}, [spaceId, spaces, setSpaceId])
// If current space disappeared (soft-deleted), fall back to first active
useEffect(() => {
if (spaceId && spaces.length > 0 && !spaces.some((s) => s.id === spaceId)) {
const next = spaces.find((s) => s.kind === MAIN_KIND) ?? spaces[0]
if (next) setSpaceId(next.id)
}
}, [spaceId, spaces, setSpaceId])
function selectSpace(space: SpaceDto) {
if (space.id === currentId) return
setSpaceId(space.id)
@@ -77,9 +94,27 @@ export function SpaceSwitcher() {
}
}
async function handleSoftDelete(space: SpaceDto) {
try {
await api.softDeleteSpace(space.id)
if (spaceId === space.id) {
const next =
spaces.find((s) => s.id !== space.id && s.kind === MAIN_KIND) ??
spaces.find((s) => s.id !== space.id)
if (next) setSpaceId(next.id)
}
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
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>
<div className="text-muted-foreground px-2 py-1.5 text-xs">Пространства</div>
)
}
@@ -97,7 +132,7 @@ export function SpaceSwitcher() {
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-muted text-muted-foreground"
className="bg-muted text-muted-foreground flex aspect-square size-8 items-center justify-center rounded-md"
aria-hidden
>
<UsersIcon className="size-4" />
@@ -106,7 +141,7 @@ export function SpaceSwitcher() {
<span className="truncate font-semibold">
{current?.name ?? 'Пространство'}
</span>
<span className="truncate text-xs text-muted-foreground">
<span className="text-muted-foreground truncate text-xs">
{current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''}
</span>
@@ -122,10 +157,7 @@ export function SpaceSwitcher() {
<DropdownMenuGroup>
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => (
<DropdownMenuItem
key={s.id}
onClick={() => selectSpace(s)}
>
<DropdownMenuItem key={s.id} onClick={() => selectSpace(s)}>
<UsersIcon className="size-4" />
<span className="truncate">{s.name}</span>
{s.id === current?.id ? (
@@ -140,6 +172,24 @@ export function SpaceSwitcher() {
<PlusIcon className="size-4" />
Создать пространство
</DropdownMenuItem>
{current && current.kind !== MAIN_KIND ? (
<ConfirmDialog
title="В корзину?"
description="Пространство скроется из списка. Данные сохранятся — можно восстановить на странице «Пространство»."
confirmLabel="В корзину"
destructive
onConfirm={() => void handleSoftDelete(current)}
trigger={
<DropdownMenuItem
onClick={(e) => e.preventDefault()}
className="text-destructive"
>
<Trash2Icon className="size-4" />
Удалить «{current.name}»
</DropdownMenuItem>
}
/>
) : null}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -34,8 +34,10 @@ export function SettingsCard({
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className={cn('gap-0 border-b px-5 py-3', headerClassName)}>
<FramePanel className="flex flex-col gap-0 p-0">
<FrameHeader
className={cn('gap-0.5 border-b border-border/50 px-5 py-3', headerClassName)}
>
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
@@ -44,7 +46,10 @@ export function SettingsCard({
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
className={cn(
'justify-end gap-2 border-t border-border/50 px-5 py-3',
footerClassName,
)}
>
{footer}
</FrameFooter>
+43 -41
View File
@@ -6,7 +6,6 @@ import {
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@cfdm/ui/components/field'
@@ -23,7 +22,10 @@ export interface SettingRowProps {
titleAddon?: ReactNode
}
/** Compact settings row — preview https://reui.io/preview/base/settings-3 */
/**
* Compact settings row — preview https://reui.io/preview/base/settings-3 · settings-2
* Hairline divider via border-b (no FieldSeparator h-5).
*/
export function SettingRow({
title,
description,
@@ -37,49 +39,49 @@ export function SettingRow({
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn('gap-4 px-5 py-4', className)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn(
'gap-4 px-5 py-3.5',
!last && 'border-border/40 border-b',
className,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
<FieldContent
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
'flex w-full justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
<div
className={cn(
'flex w-full justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
{children}
</div>
</FieldContent>
</Field>
)
}
+35 -1
View File
@@ -206,7 +206,10 @@ export const api = {
return res.blob()
},
fetchSpaces: () => fetchApi<import('@/lib/space').SpaceDto[]>('/api/spaces'),
fetchSpaces: (opts?: { deleted?: boolean }) =>
fetchApi<import('@/lib/space').SpaceDto[]>(
opts?.deleted ? '/api/spaces?deleted=1' : '/api/spaces',
),
createSpace: (body: { name: string; slug?: string }) =>
fetchApi<import('@/lib/space').SpaceDto>('/api/spaces', {
@@ -214,6 +217,37 @@ export const api = {
body: JSON.stringify(body),
}),
softDeleteSpace: (spaceId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}`,
{ method: 'DELETE' },
),
restoreSpace: (spaceId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}/restore`,
{ method: 'POST', body: '{}' },
),
purgeSpace: (spaceId: string) =>
fetchApi(`/api/spaces/${encodeURIComponent(spaceId)}/purge`, {
method: 'DELETE',
}),
transferSpaceOwnership: (spaceId: string, newOwnerUserId: string) =>
fetchApi<import('@/lib/space').SpaceDto>(
`/api/spaces/${encodeURIComponent(spaceId)}/transfer-ownership`,
{
method: 'POST',
body: JSON.stringify({ newOwnerUserId }),
},
),
searchPortalUsers: (q: string) =>
fetchApi<{ id: string; email: string; name: string }[]>(
`/api/portal-users?q=${encodeURIComponent(q)}`,
),
fetchSpaceMembers: (spaceId: string) =>
fetchApi<{ spaceId: string; userId: string; role: string; createdAt: string }[]>(
`/api/spaces/${encodeURIComponent(spaceId)}/members`,
+1
View File
@@ -18,6 +18,7 @@ export type SpaceDto = {
kind: string
ownerUserId: string | null
createdAt: string
deletedAt?: string | null
role?: string
}
+7
View File
@@ -19,6 +19,7 @@ export const snapshotQueryOptions = (spaceId?: string | null) => {
export const spacesKeys = {
all: ['spaces'] as const,
deleted: ['spaces', 'deleted'] as const,
members: (spaceId: string) => ['spaces', spaceId, 'members'] as const,
}
@@ -28,6 +29,12 @@ export const spacesQueryOptions = () => ({
staleTime: 60_000,
})
export const deletedSpacesQueryOptions = () => ({
queryKey: spacesKeys.deleted,
queryFn: () => api.fetchSpaces({ deleted: true }),
staleTime: 30_000,
})
export const ratesKeys = {
all: ['rates'] as const,
}
+16 -5
View File
@@ -5,8 +5,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useCallback } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { snapshotQueryOptions, spacesKeys } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { useSpaceId } from '@/lib/space'
const MAIN_SPACE_ID = 'space-main'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { LoadingButton } from '@/components/loading-button'
@@ -47,6 +50,7 @@ function SettingsSkeleton() {
function SettingsGeneralPage() {
const queryClient = useQueryClient()
const { setSpaceId } = useSpaceId()
const { data: snapshot, current, isLoading, isError, error, refetch } =
useSettingsSnapshot()
const patchMut = useSettingsPatch({ successMessage: 'Настройки интерфейса сохранены' })
@@ -59,10 +63,17 @@ function SettingsGeneralPage() {
values: formValues,
})
async function afterBackupImport() {
setSpaceId(MAIN_SPACE_ID)
await queryClient.invalidateQueries({ queryKey: spacesKeys.all })
await queryClient.invalidateQueries({ queryKey: spacesKeys.deleted })
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
}
const importJsonMut = useMutation({
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
onSuccess: async () => {
await afterBackupImport()
toast.success('Импорт JSON выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
@@ -70,8 +81,8 @@ function SettingsGeneralPage() {
const importDbMut = useMutation({
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
onSuccess: async () => {
await afterBackupImport()
toast.success('Импорт SQLite выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
+310 -63
View File
@@ -1,10 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useMemo, 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,
@@ -22,12 +21,20 @@ import {
TableRow,
} from '@cfdm/ui/components/table'
import { AutoCompleteInput } from '@/components/auto-complete-input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { SettingsCard } from '@/components/reui-kit/settings-card'
import { api } from '@/lib/api-client'
import { useSpaceId } from '@/lib/space'
import { spacesKeys, spacesQueryOptions } from '@/queries/snapshot'
import {
deletedSpacesQueryOptions,
spacesKeys,
spacesQueryOptions,
snapshotKeys,
} from '@/queries/snapshot'
export const Route = createFileRoute('/_auth/spaces')({
component: SpacesPage,
@@ -40,12 +47,17 @@ type MemberRow = {
createdAt: string
}
type PortalUser = { id: string; email: string; name: string }
function SpacesPage() {
const qc = useQueryClient()
const { spaceId } = useSpaceId()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const { data: trash = [] } = useQuery(deletedSpacesQueryOptions())
const current = spaces.find((s) => s.id === spaceId) ?? spaces[0]
const currentId = current?.id ?? ''
const myRole = current?.role ?? 'viewer'
const canAdmin = myRole === 'owner' || myRole === 'admin'
const membersQuery = useQuery({
queryKey: spacesKeys.members(currentId),
@@ -53,14 +65,75 @@ function SpacesPage() {
enabled: Boolean(currentId),
})
const [userId, setUserId] = useState('')
const [userQuery, setUserQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [role, setRole] = useState('member')
const [portalUsers, setPortalUsers] = useState<PortalUser[]>([])
const [memberLabels, setMemberLabels] = useState<Record<string, string>>({})
const [transferUserId, setTransferUserId] = useState('')
const [transferQuery, setTransferQuery] = useState('')
useEffect(() => {
const q = userQuery.trim() || transferQuery.trim()
const t = setTimeout(() => {
void api
.searchPortalUsers(q)
.then(setPortalUsers)
.catch(() => setPortalUsers([]))
}, q ? 250 : 0)
return () => clearTimeout(t)
}, [userQuery, transferQuery])
// Resolve names for current members (search by userId)
useEffect(() => {
const members = membersQuery.data
if (!members?.length) return
let cancelled = false
void (async () => {
const next: Record<string, string> = {}
await Promise.all(
members.map(async (m) => {
try {
const found = await api.searchPortalUsers(m.userId)
const u = found.find((x) => x.id === m.userId) ?? found[0]
if (u && u.id === m.userId) {
next[m.userId] = `${u.name} · ${u.email}`
}
} catch {
/* ignore */
}
}),
)
if (!cancelled) setMemberLabels((prev) => ({ ...prev, ...next }))
})()
return () => {
cancelled = true
}
}, [membersQuery.data])
const userOptions = useMemo(
() =>
portalUsers.map((u) => ({
value: u.id,
label: `${u.name} · ${u.email}`,
})),
[portalUsers],
)
const userLabelById = useMemo(() => {
const map = new Map<string, string>(Object.entries(memberLabels))
for (const u of portalUsers) {
map.set(u.id, `${u.name} · ${u.email}`)
}
return map
}, [portalUsers, memberLabels])
const addMutation = useMutation({
mutationFn: () =>
api.addSpaceMember(currentId, { userId: userId.trim(), role }),
api.addSpaceMember(currentId, { userId: selectedUserId.trim(), role }),
onSuccess: async () => {
setUserId('')
setSelectedUserId('')
setUserQuery('')
toast.success('Участник добавлен')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
@@ -70,12 +143,44 @@ function SpacesPage() {
const removeMutation = useMutation({
mutationFn: (uid: string) => api.removeSpaceMember(currentId, uid),
onSuccess: async () => {
toast.success('Участник удалён')
toast.success('Доступ отозван')
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
const transferMutation = useMutation({
mutationFn: () => api.transferSpaceOwnership(currentId, transferUserId.trim()),
onSuccess: async () => {
setTransferUserId('')
setTransferQuery('')
toast.success('Владение передано')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.members(currentId) })
},
onError: (e: Error) => toast.error(e.message),
})
const restoreMutation = useMutation({
mutationFn: (id: string) => api.restoreSpace(id),
onSuccess: async () => {
toast.success('Пространство восстановлено')
await qc.invalidateQueries({ queryKey: spacesKeys.all })
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
},
onError: (e: Error) => toast.error(e.message),
})
const purgeMutation = useMutation({
mutationFn: (id: string) => api.purgeSpace(id),
onSuccess: async () => {
toast.success('Пространство удалено навсегда')
await qc.invalidateQueries({ queryKey: spacesKeys.deleted })
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
},
onError: (e: Error) => toast.error(e.message),
})
return (
<PageShell>
<PageHeader
@@ -87,80 +192,222 @@ function SpacesPage() {
}
/>
<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 className="flex flex-col gap-4">
{myRole === 'owner' ? (
<SettingsCard
title="Владелец"
description="Передать владение другому пользователю из auth-portal"
footer={
<ConfirmDialog
title="Передать владение?"
description="Вы станете admin. Новый владелец получит полный контроль."
confirmLabel="Передать"
onConfirm={() => transferMutation.mutate()}
trigger={
<Button
type="button"
disabled={!transferUserId.trim() || transferMutation.isPending}
>
Передать владение
</Button>
}
/>
}
>
<div className="flex flex-col gap-2 px-5 py-4">
<Label>Новый владелец</Label>
<AutoCompleteInput
value={
transferUserId
? (userLabelById.get(transferUserId) ?? transferQuery)
: transferQuery
}
onChange={(v) => {
const match = userOptions.find(
(o) => o.value === v || o.label === v,
)
if (match) {
setTransferUserId(match.value)
setTransferQuery(match.label)
} else {
setTransferUserId('')
setTransferQuery(v)
}
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</div>
</SettingsCard>
) : null}
<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="Добавьте пользователя по имени или email"
>
{(members) => (
<SettingsCard title="Участники" description="Доступ к текущему пространству">
{canAdmin ? (
<div className="flex flex-col gap-3 border-b border-border/40 px-5 py-4 md:flex-row md:items-end">
<div className="flex flex-1 flex-col gap-2">
<Label>Пользователь</Label>
<AutoCompleteInput
value={
selectedUserId
? (userLabelById.get(selectedUserId) ?? userQuery)
: userQuery
}
onChange={(v) => {
const match = userOptions.find(
(o) => o.value === v || o.label === v,
)
if (match) {
setSelectedUserId(match.value)
setUserQuery(match.label)
} else {
setSelectedUserId('')
setUserQuery(v)
}
}}
options={userOptions}
placeholder="Имя или email…"
allowFreeText={false}
emptyText="Пользователи не найдены"
/>
</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={!selectedUserId.trim() || addMutation.isPending}
onClick={() => addMutation.mutate()}
>
Добавить
</Button>
</div>
) : null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-36" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={`${m.spaceId}-${m.userId}`}>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm">
{userLabelById.get(m.userId) ?? m.userId}
</span>
{!userLabelById.has(m.userId) ? (
<span className="text-muted-foreground font-mono text-xs">
{m.userId}
</span>
) : null}
</div>
</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
{canAdmin && m.role !== 'owner' ? (
<ConfirmDialog
title="Отозвать доступ?"
description="Пользователь потеряет доступ к этому пространству."
confirmLabel="Отозвать"
destructive
onConfirm={() => removeMutation.mutate(m.userId)}
trigger={
<Button variant="outline" size="sm">
Отозвать
</Button>
}
/>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SettingsCard>
)}
</QueryState>
<SettingsCard
title="Корзина"
description="Удалённые пространства можно восстановить или удалить навсегда"
>
{trash.length === 0 ? (
<p className="text-muted-foreground px-5 py-4 text-sm">Корзина пуста</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Роль</TableHead>
<TableHead className="w-28" />
<TableHead>Название</TableHead>
<TableHead>Удалено</TableHead>
<TableHead className="w-64" />
</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>
{trash.map((s) => (
<TableRow key={s.id}>
<TableCell>{s.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{s.deletedAt
? new Date(s.deletedAt).toLocaleString('ru-RU')
: '—'}
</TableCell>
<TableCell>
{m.role !== 'owner' ? (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => removeMutation.mutate(m.userId)}
variant="outline"
onClick={() => restoreMutation.mutate(s.id)}
disabled={restoreMutation.isPending}
>
Удалить
Восстановить
</Button>
) : null}
<ConfirmDialog
title="Удалить навсегда?"
description="Безвозвратно: все данные пространства будут уничтожены."
confirmLabel="Удалить навсегда"
destructive
onConfirm={() => purgeMutation.mutate(s.id)}
trigger={
<Button size="sm" variant="destructive">
Удалить навсегда
</Button>
}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</QueryState>
)}
</SettingsCard>
</div>
</PageShell>
)
}