Files
tgex-frontend/components/ui/permission-modal.tsx
2026-02-10 11:42:17 +03:00

465 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Shield, Search, X, Plus, Check, Info } from "lucide-react";
import type { WorkspaceMember, Project, PermissionKey } from "@/lib/types/api";
import { PERMISSION_LABELS, PERMISSION_GROUPS, PERMISSION_ICONS } from "@/lib/types/permission";
import {
hasGlobalPermission,
hasScopedPermission,
getScopesForPermission,
setScopedPermission,
toggleScopedPermission,
removePermission,
calculatePermissionDiff,
} from "@/lib/types/permission";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface PermissionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
member: WorkspaceMember;
projects: Project[];
onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]) => Promise<void>;
workspaceId: string;
}
interface PermissionState {
key: PermissionKey;
isGlobal: boolean;
projectIds: string[];
}
const PRESETS = [
{ label: "Полный доступ", keys: ["admin_full"] },
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
{ label: "Менеджер", keys: ["projects_read", "projects_write", "placements_read", "placements_write"] },
{ label: "Убрать все", keys: [] },
];
export function PermissionModal({
open,
onOpenChange,
member,
projects,
onSave,
workspaceId,
}: PermissionModalProps) {
const [isSaving, setIsSaving] = useState(false);
const [permissions, setPermissions] = useState<PermissionState[]>([]);
const [projectSearch, setProjectSearch] = useState("");
useEffect(() => {
if (open && member) {
const perms: PermissionState[] = [];
for (const perm of member.permissions) {
const isGlobal = !perm.scopes || perm.scopes.length === 0;
const projectIds = perm.scopes
? perm.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
perms.push({
key: perm.key,
isGlobal,
projectIds,
});
}
setPermissions(perms);
}
}, [open, member]);
const handleToggleGlobal = (key: PermissionKey) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
return prev.map((p) =>
p.key === key ? { ...p, isGlobal: !p.isGlobal, projectIds: [] } : p
);
}
return [...prev, { key, isGlobal: true, projectIds: [] }];
});
};
const handleToggleProject = (key: PermissionKey, projectId: string) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
return prev.map((p) =>
p.key === key
? { ...p, isGlobal: false, projectIds: toggleInArray(p.projectIds, projectId) }
: p
);
}
return [
...prev,
{ key, isGlobal: false, projectIds: [projectId] },
];
});
};
const handleAddProject = (key: PermissionKey, projectId: string) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
return prev.map((p) =>
p.key === key
? { ...p, isGlobal: false, projectIds: [...new Set([...p.projectIds, projectId])] }
: p
);
}
return [
...prev,
{ key, isGlobal: false, projectIds: [projectId] },
];
});
};
const handleRemoveProject = (key: PermissionKey, projectId: string) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (!existing) return prev;
const newProjectIds = existing.projectIds.filter((id) => id !== projectId);
if (newProjectIds.length === 0) {
return prev.filter((p) => p.key !== key);
}
return prev.map((p) =>
p.key === key ? { ...p, projectIds: newProjectIds } : p
);
});
};
const toggleInArray = (arr: string[], item: string): string[] => {
if (arr.includes(item)) {
return arr.filter((i) => i !== item);
}
return [...arr, item];
};
const applyPreset = (keys: PermissionKey[]) => {
if (keys.length === 0) {
setPermissions([]);
return;
}
setPermissions(
keys.map((key) => ({
key,
isGlobal: true,
projectIds: [],
}))
);
};
const getProjectById = (id: string): Project | undefined => {
return projects.find((p) => p.id === id);
};
const availableProjects = useMemo(() => {
return projects.filter(
(p) =>
p.title.toLowerCase().includes(projectSearch.toLowerCase()) ||
p.username?.toLowerCase().includes(projectSearch.toLowerCase())
);
}, [projects, projectSearch]);
const isDirty = useMemo(() => {
if (permissions.length !== member.permissions.length) return true;
for (const perm of permissions) {
const orig = member.permissions.find((p) => p.key === perm.key);
if (!orig) return true;
const origIsGlobal = !orig.scopes || orig.scopes.length === 0;
const origProjectIds = orig.scopes
? orig.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
if (perm.isGlobal !== origIsGlobal) return true;
if (!perm.isGlobal) {
const permProjectSet = new Set(perm.projectIds);
const origProjectSet = new Set(origProjectIds);
if (permProjectSet.size !== origProjectSet.size) return true;
for (const id of permProjectSet) {
if (!origProjectSet.has(id)) return true;
}
}
}
return false;
}, [permissions, member.permissions]);
const handleSave = async () => {
setIsSaving(true);
try {
const result = permissions
.filter((p) => {
if (p.isGlobal) return true;
return p.projectIds.length > 0;
})
.map((p) => {
if (p.isGlobal) {
return { key: p.key };
}
return {
key: p.key,
scopes: p.projectIds.map((id) => ({ type: "project" as const, id })),
};
});
await onSave(workspaceId, member.id, member.user.id, result);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
const perms: PermissionState[] = [];
for (const perm of member.permissions) {
const isGlobal = !perm.scopes || perm.scopes.length === 0;
const projectIds = perm.scopes
? perm.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
perms.push({
key: perm.key,
isGlobal,
projectIds,
});
}
setPermissions(perms);
};
const renderPermissionRow = (key: PermissionKey, group: string) => {
const current = permissions.find((p) => p.key === key);
const currentProjectIds = current?.projectIds || [];
const isGlobal = current?.isGlobal ?? false;
return (
<div key={key} className="space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg">{PERMISSION_ICONS[key]}</span>
<span className="font-medium truncate">{PERMISSION_LABELS[key]}</span>
<Badge variant="outline" className="text-xs shrink-0">
{group}
</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant={isGlobal ? "default" : "outline"}
size="sm"
onClick={() => handleToggleGlobal(key)}
className="text-xs"
>
Глобально
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant={!isGlobal ? "default" : "outline"} size="sm" className="text-xs">
<Search className="h-3 w-3 mr-1" />
Проекты ({currentProjectIds.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
placeholder="Поиск проекта..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="h-8"
/>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 pr-2">
{availableProjects.map((project) => {
const isSelected = currentProjectIds.includes(project.id);
return (
<div
key={project.id}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
isSelected
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
onClick={() => handleToggleProject(key, project.id)}
>
<div className="truncate">
<div className="font-medium truncate">{project.title}</div>
{project.username && (
<div className="text-xs text-muted-foreground">
@{project.username}
</div>
)}
</div>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</div>
);
})}
{availableProjects.length === 0 && (
<div className="text-center py-4 text-muted-foreground text-sm">
Проекты не найдены
</div>
)}
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{!isGlobal && currentProjectIds.length > 0 && (
<div className="flex flex-wrap gap-1 pl-8">
{currentProjectIds.map((id) => {
const project = getProjectById(id);
return (
<Badge
key={id}
variant="secondary"
className="gap-1 text-xs"
>
{project?.title || "Unknown"}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveProject(key, id)}
/>
</Badge>
);
})}
</div>
)}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Права доступа
</DialogTitle>
<DialogDescription>
Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4">
<div className="flex flex-wrap gap-2 pb-2">
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
{PRESETS.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyPreset(preset.keys as PermissionKey[])}
className="text-xs"
>
{preset.label}
</Button>
))}
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
👑 Администрирование
</h4>
{PERMISSION_GROUPS.admin.map((key) => renderPermissionRow(key, "admin"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📁 Проекты
</h4>
{PERMISSION_GROUPS.projects.map((key) => renderPermissionRow(key, "projects"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
🎨 Креативы
</h4>
{PERMISSION_GROUPS.creatives.map((key) => renderPermissionRow(key, "creatives"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📋 Закупы
</h4>
{PERMISSION_GROUPS.placements.map((key) => renderPermissionRow(key, "placements"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📊 Аналитика
</h4>
{PERMISSION_GROUPS.analytics.map((key) => renderPermissionRow(key, "analytics"))}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded">
<Info className="h-4 w-4 shrink-0" />
<span>
Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам.
</span>
</div>
</div>
<DialogFooter className="flex justify-between items-center pt-4 border-t">
<div className="text-sm text-muted-foreground">
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleReset} disabled={isSaving}>
Сбросить
</Button>
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Сохранить
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}