Files
tgex-frontend/components/ui/permission-modal.tsx
2026-02-10 18:26:21 +03:00

551 lines
20 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,
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>
);
}