551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useMemo } from "react";
|
||
import {
|
||
Loader2,
|
||
Shield,
|
||
Search,
|
||
X,
|
||
Check,
|
||
Info,
|
||
Crown,
|
||
FolderOpen,
|
||
Palette,
|
||
ClipboardList,
|
||
BarChart3,
|
||
Users,
|
||
Lock,
|
||
Eye,
|
||
Pencil,
|
||
ChartArea,
|
||
UserRoundX,
|
||
FileUser,
|
||
Globe,
|
||
} from "lucide-react";
|
||
import type { WorkspaceMember, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
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?: PermissionScope[] }[]) => Promise<void>;
|
||
workspaceId: string;
|
||
currentUserId: string;
|
||
isOnlyAdmin: boolean;
|
||
}
|
||
|
||
interface PermissionState {
|
||
key: PermissionKey;
|
||
isGlobal: boolean;
|
||
projectIds: string[];
|
||
}
|
||
|
||
const PERMISSION_CONFIG: Record<PermissionKey, { icon: typeof Crown; label: string; group: string }> = {
|
||
admin_full: { icon: Crown, label: "Полный доступ", group: "admin" },
|
||
projects_read: { icon: Eye, label: "Просмотр проектов", group: "projects" },
|
||
projects_write: { icon: Pencil, label: "Управление проектами", group: "projects" },
|
||
creatives_read: { icon: Eye, label: "Просмотр креативов", group: "creatives" },
|
||
creatives_write: { icon: Pencil, label: "Создание и редактирование креативов", group: "creatives" },
|
||
placements_read: { icon: Eye, label: "Просмотр закупок", group: "placements" },
|
||
placements_write: { icon: Pencil, label: "Создание и редактирование закупок", group: "placements" },
|
||
analytics_read: { icon: ChartArea, label: "Полный доступ к статистике", group: "analytics" },
|
||
analytics_without_clicks: { icon: UserRoundX, label: "Статистика без переходов", group: "analytics" },
|
||
analytics_own_creatives: { icon: FileUser, label: "Статистика только своих креативов", group: "analytics" },
|
||
};
|
||
|
||
const GROUPS = [
|
||
{ key: "admin", label: "Администрирование", icon: Shield },
|
||
{ key: "projects", label: "Проекты", icon: FolderOpen },
|
||
{ key: "creatives", label: "Креативы", icon: Palette },
|
||
{ key: "placements", label: "Закупки", icon: ClipboardList },
|
||
{ key: "analytics", label: "Аналитика", icon: BarChart3 },
|
||
] as const;
|
||
|
||
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,
|
||
currentUserId,
|
||
isOnlyAdmin,
|
||
}: 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 hasGlobalAdmin = useMemo(() => {
|
||
const admin = permissions.find((p) => p.key === "admin_full");
|
||
return admin?.isGlobal ?? false;
|
||
}, [permissions]);
|
||
|
||
const isPermissionDisabled = (key: PermissionKey): boolean => {
|
||
return hasGlobalAdmin && key !== "admin_full";
|
||
};
|
||
|
||
const handleToggleGlobal = (key: PermissionKey) => {
|
||
if (hasGlobalAdmin && key !== "admin_full") return;
|
||
|
||
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) => {
|
||
if (hasGlobalAdmin && key !== "admin_full") return;
|
||
|
||
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 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 isEditingOwnPermissions = member.user.id === currentUserId;
|
||
|
||
const renderPermissionRow = (key: PermissionKey) => {
|
||
const config = PERMISSION_CONFIG[key];
|
||
const current = permissions.find((p) => p.key === key);
|
||
const currentProjectIds = current?.projectIds || [];
|
||
const isGlobal = current?.isGlobal ?? false;
|
||
const isDisabled = isPermissionDisabled(key);
|
||
|
||
return (
|
||
<div
|
||
key={key}
|
||
className={cn(
|
||
"space-y-2 transition-opacity",
|
||
isDisabled && "opacity-50"
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 min-w-0">
|
||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||
{isDisabled ? (
|
||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
) : (
|
||
<config.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
)}
|
||
<span className={cn("font-medium text-sm truncate", isDisabled && "text-muted-foreground")}>
|
||
{config.label}
|
||
</span>
|
||
{isDisabled && (
|
||
<Badge variant="secondary" className="text-xs shrink-0">
|
||
Выдан полный доступ
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Button
|
||
variant={isGlobal ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => handleToggleGlobal(key)}
|
||
disabled={isDisabled}
|
||
className={cn(
|
||
"text-xs shrink-0",
|
||
isDisabled && "cursor-not-allowed opacity-50"
|
||
)}
|
||
>
|
||
<Globe className="h-3 w-3 mr-1" />
|
||
Глобально
|
||
</Button>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant={!isGlobal ? "default" : "outline"}
|
||
size="sm"
|
||
disabled={isDisabled}
|
||
className={cn(
|
||
"text-xs shrink-0 min-w-[100px]",
|
||
isDisabled && "cursor-not-allowed opacity-50"
|
||
)}
|
||
>
|
||
<Search className="h-3 w-3 mr-1" />
|
||
{currentProjectIds.length > 0
|
||
? `${currentProjectIds.length} проект${currentProjectIds.length === 1 ? "" : currentProjectIds.length > 4 ? "ов" : "а"}`
|
||
: "Проекты"}
|
||
</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 gap-2 p-2 rounded-md cursor-pointer transition-colors",
|
||
isSelected
|
||
? "bg-primary/10 text-primary"
|
||
: "hover:bg-muted"
|
||
)}
|
||
onClick={() => handleToggleProject(key, project.id)}
|
||
>
|
||
<Avatar className="h-6 w-6 shrink-0">
|
||
<AvatarImage src={project.username ? `https://t.me/i/userpic/320/${project.username}.jpg` : undefined} />
|
||
<AvatarFallback className="text-xs">
|
||
{project.title.charAt(0).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="truncate min-w-0 flex-1">
|
||
<div className="font-medium text-sm truncate">{project.title}</div>
|
||
{project.username && (
|
||
<div className="text-xs text-muted-foreground truncate">
|
||
@{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 ml-6">
|
||
{currentProjectIds.map((id) => {
|
||
const project = getProjectById(id);
|
||
return (
|
||
<Badge
|
||
key={id}
|
||
variant="secondary"
|
||
className={cn("gap-1 text-xs", isDisabled && "opacity-50")}
|
||
>
|
||
<Avatar className="h-4 w-4">
|
||
<AvatarImage src={project?.username ? `https://t.me/i/userpic/320/${project.username}.jpg` : undefined} />
|
||
<AvatarFallback className="text-[10px]">
|
||
{project?.title.charAt(0).toUpperCase() || "?"}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="truncate max-w-[150px]">{project?.title || "Unknown"}</span>
|
||
<span
|
||
className={cn(
|
||
"h-3 w-3 ml-1 cursor-pointer hover:text-destructive",
|
||
isDisabled && "cursor-not-allowed text-muted-foreground/50"
|
||
)}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (isDisabled) return;
|
||
setPermissions((prev) => {
|
||
const existing = prev.find((p) => p.key === key);
|
||
if (!existing) return prev;
|
||
const newProjectIds = existing.projectIds.filter((pid) => pid !== id);
|
||
if (newProjectIds.length === 0) {
|
||
return prev.filter((p) => p.key !== key);
|
||
}
|
||
return prev.map((p) =>
|
||
p.key === key ? { ...p, projectIds: newProjectIds } : p
|
||
);
|
||
});
|
||
}}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</span>
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-3xl w-[98vw] max-h-[90vh] flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Users className="h-5 w-5" />
|
||
Права доступа
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Настройте права доступа для участника{" "}
|
||
<span className="font-medium">
|
||
{member.user.first_name || member.user.username || `User ${member.user.telegram_id}`}
|
||
</span>
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{isEditingOwnPermissions && isOnlyAdmin && (
|
||
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
|
||
<Lock className="h-4 w-4 shrink-0" />
|
||
<span>
|
||
Вы не можете изменить свои права, так как вы единственный администратор воркспейса.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 overflow-y-auto space-y-4 px-1">
|
||
<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">
|
||
{GROUPS.slice(1).map((group) => {
|
||
const groupPermissions = Object.entries(PERMISSION_CONFIG)
|
||
.filter(([, config]) => config.group === group.key)
|
||
.map(([key, config]) => ({ ...config, key: key as PermissionKey }));
|
||
|
||
if (groupPermissions.length === 0) return null;
|
||
|
||
return (
|
||
<div key={group.key} className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||
<group.icon className="h-4 w-4" />
|
||
{group.label}
|
||
</div>
|
||
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
|
||
{groupPermissions.map((perm) => renderPermissionRow(perm.key))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||
<Shield className="h-4 w-4" />
|
||
Администрирование
|
||
</div>
|
||
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
|
||
{Object.entries(PERMISSION_CONFIG)
|
||
.filter(([, config]) => config.group === "admin")
|
||
.map(([key, config]) => ({ ...config, key: key as PermissionKey }))
|
||
.map((perm) => renderPermissionRow(perm.key))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
|
||
<Info className="h-4 w-4 shrink-0" />
|
||
<span>
|
||
<strong>Полный доступ</strong> автоматически включает все остальные права.
|
||
Остальные права можно оставить для случая, если позже потребуется убрать полный доступ.
|
||
</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 || (isEditingOwnPermissions && isOnlyAdmin)}>
|
||
Сбросить
|
||
</Button>
|
||
<Button
|
||
onClick={handleSave}
|
||
disabled={!isDirty || isSaving || (isEditingOwnPermissions && isOnlyAdmin)}
|
||
>
|
||
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|