feat: update permissions

This commit is contained in:
ivannoskov
2026-02-10 17:15:38 +03:00
parent 6941915d76
commit e1117e46c8
13 changed files with 961 additions and 523 deletions

View File

@@ -1,23 +1,28 @@
"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";
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,
@@ -31,7 +36,12 @@ import {
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 {
@@ -39,8 +49,10 @@ interface PermissionModalProps {
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>;
onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]) => Promise<void>;
workspaceId: string;
currentUserId: string;
isOnlyAdmin: boolean;
}
interface PermissionState {
@@ -49,6 +61,27 @@ interface PermissionState {
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"] },
@@ -63,6 +96,8 @@ export function PermissionModal({
projects,
onSave,
workspaceId,
currentUserId,
isOnlyAdmin,
}: PermissionModalProps) {
const [isSaving, setIsSaving] = useState(false);
const [permissions, setPermissions] = useState<PermissionState[]>([]);
@@ -89,7 +124,18 @@ export function PermissionModal({
}
}, [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) {
@@ -102,6 +148,8 @@ export function PermissionModal({
};
const handleToggleProject = (key: PermissionKey, projectId: string) => {
if (hasGlobalAdmin && key !== "admin_full") return;
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
@@ -118,40 +166,6 @@ export function PermissionModal({
});
};
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);
@@ -255,35 +269,68 @@ export function PermissionModal({
setPermissions(perms);
};
const renderPermissionRow = (key: PermissionKey, group: string) => {
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="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
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)}
className="text-xs"
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" className="text-xs">
<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})
{currentProjectIds.length > 0
? `${currentProjectIds.length} проект${currentProjectIds.length === 1 ? "" : currentProjectIds.length > 4 ? "ов" : "а"}`
: "Проекты"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
@@ -304,17 +351,23 @@ export function PermissionModal({
<div
key={project.id}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
"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)}
>
<div className="truncate">
<div className="font-medium truncate">{project.title}</div>
<Avatar className="h-6 w-6 shrink-0">
<AvatarImage src={project.username ? undefined : 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">
<div className="text-xs text-muted-foreground truncate">
@{project.username}
</div>
)}
@@ -337,19 +390,42 @@ export function PermissionModal({
</div>
{!isGlobal && currentProjectIds.length > 0 && (
<div className="flex flex-wrap gap-1 pl-8">
<div className="flex flex-wrap gap-1 ml-6">
{currentProjectIds.map((id) => {
const project = getProjectById(id);
return (
<Badge
key={id}
variant="secondary"
className="gap-1 text-xs"
className={cn("gap-1 text-xs", isDisabled && "opacity-50")}
>
{project?.title || "Unknown"}
<Avatar className="h-4 w-4">
<AvatarFallback className="text-[10px]">
{project?.title.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<span className="truncate max-w-[150px]">{project?.title || "Unknown"}</span>
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveProject(key, id)}
className={cn(
"h-3 w-3 ml-1",
isDisabled
? "cursor-not-allowed text-muted-foreground/50"
: "cursor-pointer hover:text-destructive"
)}
onClick={() => {
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
);
});
}}
/>
</Badge>
);
@@ -362,18 +438,30 @@ export function PermissionModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-3xl w-[98vw] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<Users className="h-5 w-5" />
Права доступа
</DialogTitle>
<DialogDescription>
Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`}
Настройте права доступа для участника{" "}
<span className="font-medium">
{member.user.first_name || member.user.username || `User ${member.user.telegram_id}`}
</span>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4">
{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) => (
@@ -392,54 +480,47 @@ export function PermissionModal({
<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"))}
{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>
<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 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-2 rounded">
<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>
@@ -449,10 +530,13 @@ export function PermissionModal({
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleReset} disabled={isSaving}>
<Button variant="outline" onClick={handleReset} disabled={isSaving || (isEditingOwnPermissions && isOnlyAdmin)}>
Сбросить
</Button>
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
<Button
onClick={handleSave}
disabled={!isDirty || isSaving || (isEditingOwnPermissions && isOnlyAdmin)}
>
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Сохранить
</Button>