feat: update purchase plans
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -30,14 +30,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -58,8 +50,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api";
|
||||
import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api";
|
||||
import { PERMISSION_LABELS } from "@/lib/types/api";
|
||||
import { PermissionModal } from "@/components/ui/permission-modal";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -93,6 +85,12 @@ export default function SettingsPage() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Permission modal
|
||||
const [showPermissionModal, setShowPermissionModal] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<WorkspaceMember | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspace) {
|
||||
setName(currentWorkspace.name);
|
||||
@@ -119,6 +117,34 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoadingProjects(true);
|
||||
const response = await workspacesApi.getProjects(workspaceId, { size: 100 });
|
||||
setProjects(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load projects:", err);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openPermissionModal = async (member: WorkspaceMember) => {
|
||||
setSelectedMember(member);
|
||||
await loadProjects();
|
||||
setShowPermissionModal(true);
|
||||
};
|
||||
|
||||
const handlePermissionSave = async (
|
||||
wsId: string,
|
||||
memberId: string,
|
||||
userId: string,
|
||||
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
|
||||
) => {
|
||||
await membersApi.updatePermissions(wsId, userId, { permissions });
|
||||
await loadMembers();
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!name.trim() || name === currentWorkspace?.name) return;
|
||||
|
||||
@@ -152,82 +178,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
try {
|
||||
setIsDeletingAvatar(true);
|
||||
await workspacesApi.deleteAvatar(workspaceId);
|
||||
await refreshWorkspaces();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete avatar:", err);
|
||||
} finally {
|
||||
setIsDeletingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
if (!username.trim()) return;
|
||||
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowInviteDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
||||
} else {
|
||||
setError(errorMessage || "Не удалось отправить приглашение");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermissionChange = async (
|
||||
memberId: string,
|
||||
userId: string,
|
||||
permission: PermissionKey,
|
||||
enabled: boolean
|
||||
) => {
|
||||
try {
|
||||
setUpdating(memberId);
|
||||
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
let newPermissions = [...member.permissions];
|
||||
|
||||
if (enabled) {
|
||||
if (!newPermissions.some((p) => p.key === permission)) {
|
||||
newPermissions.push({ key: permission });
|
||||
}
|
||||
} else {
|
||||
newPermissions = newPermissions.filter((p) => p.key !== permission);
|
||||
}
|
||||
|
||||
await membersApi.updatePermissions(workspaceId, userId, {
|
||||
permissions: newPermissions,
|
||||
});
|
||||
|
||||
setMembers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === memberId ? { ...m, permissions: newPermissions } : m
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update permissions:", err);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
|
||||
|
||||
@@ -248,6 +198,45 @@ export default function SettingsPage() {
|
||||
return member.permissions.some((p) => p.key === key);
|
||||
};
|
||||
|
||||
const renderPermissionBadges = (member: WorkspaceMember) => {
|
||||
if (member.permissions.length === 0) {
|
||||
return <span className="text-sm text-muted-foreground">Нет прав</span>;
|
||||
}
|
||||
|
||||
const adminPerm = member.permissions.find((p) => p.key === "admin_full");
|
||||
if (adminPerm) {
|
||||
const isGlobal = !adminPerm.scopes || adminPerm.scopes.length === 0;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Админ {isGlobal ? "" : "(ограничен)"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{member.permissions.map((p) => {
|
||||
const isGlobal = !p.scopes || p.scopes.length === 0;
|
||||
const projectCount = p.scopes
|
||||
? p.scopes.filter((s) => s.type === "project").length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Badge key={p.key} variant="outline" className="gap-1">
|
||||
{PERMISSION_LABELS[p.key]}
|
||||
{!isGlobal && projectCount > 0 && (
|
||||
<span className="text-muted-foreground">({projectCount})</span>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Combine members and pending invites
|
||||
const pendingInvites = invites.filter((inv) => inv.status === "pending");
|
||||
const allPeople = [
|
||||
@@ -544,65 +533,23 @@ export default function SettingsPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hasPermission(member, "admin_full") ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Админ
|
||||
</Badge>
|
||||
) : (
|
||||
member.permissions.map((p) => (
|
||||
<Badge key={p.key} variant="outline">
|
||||
{PERMISSION_LABELS[p.key]}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{member.permissions.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Нет прав
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderPermissionBadges(member)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openPermissionModal(member)}
|
||||
disabled={updating === member.id}
|
||||
>
|
||||
{updating === member.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Права
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
Настроить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{ALL_PERMISSIONS.map((perm) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={perm}
|
||||
checked={hasPermission(member, perm)}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePermissionChange(
|
||||
member.id,
|
||||
member.user.id,
|
||||
perm,
|
||||
checked
|
||||
)
|
||||
}
|
||||
>
|
||||
{PERMISSION_LABELS[perm]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -681,6 +628,17 @@ export default function SettingsPage() {
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedMember && (
|
||||
<PermissionModal
|
||||
open={showPermissionModal}
|
||||
onOpenChange={setShowPermissionModal}
|
||||
member={selectedMember}
|
||||
projects={projects}
|
||||
onSave={handlePermissionSave}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
464
components/ui/permission-modal.tsx
Normal file
464
components/ui/permission-modal.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:outline-hidden size-full rounded-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Workspace,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceUpdateRequest,
|
||||
Project,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
} from "@/lib/types/api";
|
||||
@@ -52,5 +53,11 @@ export const workspacesApi = {
|
||||
*/
|
||||
deleteAvatar: (workspaceId: string) =>
|
||||
api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`),
|
||||
|
||||
/**
|
||||
* Get projects for workspace
|
||||
*/
|
||||
getProjects: (workspaceId: string, params?: PaginationParams) =>
|
||||
api.get<PaginatedResponse<Project>>(`${BASE_PATH}/${workspaceId}/projects`, { params }),
|
||||
};
|
||||
|
||||
|
||||
@@ -21,51 +21,51 @@ export const COLUMN_GROUPS = [
|
||||
] as const;
|
||||
|
||||
export const ALL_COLUMNS: ColumnConfig[] = [
|
||||
// Basic
|
||||
{ id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true },
|
||||
{ id: "number", label: "Номер", group: "basic", editable: false, required: true },
|
||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true },
|
||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true },
|
||||
// Basic - узкие колонки
|
||||
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" },
|
||||
{ id: "number", label: "№", group: "basic", editable: false, required: true, width: "40px" },
|
||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
||||
|
||||
// Content
|
||||
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
||||
{ id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false },
|
||||
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||
{ id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" },
|
||||
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
|
||||
// Finance
|
||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true },
|
||||
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
|
||||
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true, partialEdit: true },
|
||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
|
||||
{ id: "discount", label: "% скидки", group: "finance", editable: false },
|
||||
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
|
||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" },
|
||||
{ id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" },
|
||||
|
||||
// Dates
|
||||
{ id: "planned_date", label: "Плановая дата", group: "dates", editable: true },
|
||||
{ id: "actual_date", label: "Фактическая дата", group: "dates", editable: false },
|
||||
{ id: "format", label: "Плановый формат", group: "dates", editable: true },
|
||||
{ id: "time_top", label: "Время в топе", group: "dates", editable: false },
|
||||
{ id: "time_in_feed", label: "Время в ленте", group: "dates", editable: false },
|
||||
{ id: "link_created", label: "Дата ссылки", group: "dates", editable: false },
|
||||
{ id: "post_deleted", label: "Дата удаления", group: "dates", editable: false },
|
||||
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" },
|
||||
{ id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" },
|
||||
{ id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" },
|
||||
{ id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
||||
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
||||
|
||||
// Metrics
|
||||
{ id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false },
|
||||
{ id: "views", label: "Просмотры", group: "metrics", editable: false },
|
||||
{ id: "subscriptions", label: "Подписки", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
{ id: "views", label: "Просмотры", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
|
||||
// Calculated
|
||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
|
||||
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true },
|
||||
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false },
|
||||
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
|
||||
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },
|
||||
{ id: "total_unsubs", label: "Всего отписок", group: "calculated", editable: false },
|
||||
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false },
|
||||
{ id: "total_active", label: "Итого подписок", group: "calculated", editable: false },
|
||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
|
||||
// Technical
|
||||
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true },
|
||||
{ id: "comment", label: "Комментарий", group: "technical", editable: true },
|
||||
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" },
|
||||
{ id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" },
|
||||
] as const;
|
||||
|
||||
// Default visible columns
|
||||
@@ -81,9 +81,9 @@ export const DEFAULT_VISIBLE_COLUMNS = new Set([
|
||||
"cost_format",
|
||||
"planned_date",
|
||||
"format",
|
||||
"subscriptions",
|
||||
"views",
|
||||
"cpf",
|
||||
// "subscriptions",
|
||||
// "views",
|
||||
// "cpf",
|
||||
"cpm",
|
||||
"comment",
|
||||
]);
|
||||
|
||||
205
lib/types/permission.ts
Normal file
205
lib/types/permission.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { PermissionKey, Permission, PermissionScope, PermissionScopeType } from "./api";
|
||||
|
||||
export const PERMISSION_GROUPS = {
|
||||
admin: ["admin_full"] as const,
|
||||
projects: ["projects_read", "projects_write"] as const,
|
||||
creatives: ["creatives_read", "creatives_write"] as const,
|
||||
placements: ["placements_read", "placements_write"] as const,
|
||||
analytics: ["analytics_read", "analytics_without_clicks", "analytics_own_creatives"] as const,
|
||||
} as const;
|
||||
|
||||
export type PermissionGroup = keyof typeof PERMISSION_GROUPS;
|
||||
|
||||
export const PERMISSION_ICONS: Record<PermissionKey, string> = {
|
||||
admin_full: "👑",
|
||||
projects_read: "📁",
|
||||
projects_write: "📁",
|
||||
creatives_read: "🎨",
|
||||
creatives_write: "🎨",
|
||||
placements_read: "📋",
|
||||
placements_write: "📋",
|
||||
analytics_read: "📊",
|
||||
analytics_without_clicks: "📊",
|
||||
analytics_own_creatives: "📊",
|
||||
};
|
||||
|
||||
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
admin_full: "Полный доступ к администрированию",
|
||||
projects_read: "Просмотр проектов",
|
||||
projects_write: "Управление проектами",
|
||||
creatives_read: "Просмотр креативов",
|
||||
creatives_write: "Управление креативами",
|
||||
placements_read: "Просмотр размещений",
|
||||
placements_write: "Управление размещениями",
|
||||
analytics_read: "Просмотр аналитики",
|
||||
analytics_without_clicks: "Аналитика без кликов",
|
||||
analytics_own_creatives: "Аналитика своих креативов",
|
||||
};
|
||||
|
||||
export function hasGlobalPermission(permissions: Permission[], key: PermissionKey): boolean {
|
||||
return permissions.some((p) => p.key === key && (!p.scopes || p.scopes.length === 0));
|
||||
}
|
||||
|
||||
export function hasScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeId: string
|
||||
): boolean {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return false;
|
||||
return perm.scopes.some((s) => s.type === scopeType && s.id === scopeId);
|
||||
}
|
||||
|
||||
export function getScopesForPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType
|
||||
): string[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return [];
|
||||
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
|
||||
}
|
||||
|
||||
export function addGlobalPermission(permissions: Permission[], key: PermissionKey): Permission[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (perm) {
|
||||
if (!perm.scopes || perm.scopes.length === 0) {
|
||||
return permissions;
|
||||
}
|
||||
const newPerms = permissions.filter((p) => p.key !== key);
|
||||
return [...newPerms, { key, scopes: undefined }];
|
||||
}
|
||||
return [...permissions, { key, scopes: undefined }];
|
||||
}
|
||||
|
||||
export function removePermission(permissions: Permission[], key: PermissionKey): Permission[] {
|
||||
return permissions.filter((p) => p.key !== key);
|
||||
}
|
||||
|
||||
export function setScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeIds: string[]
|
||||
): Permission[] {
|
||||
const existing = permissions.find((p) => p.key === key);
|
||||
const otherPerms = permissions.filter((p) => p.key !== key);
|
||||
|
||||
if (scopeIds.length === 0) {
|
||||
return otherPerms;
|
||||
}
|
||||
|
||||
const existingScopes = existing?.scopes?.filter((s) => s.type !== scopeType) || [];
|
||||
const newScopes = [...existingScopes, ...scopeIds.map((id) => ({ type: scopeType, id }))];
|
||||
|
||||
return [...otherPerms, { key, scopes: newScopes }];
|
||||
}
|
||||
|
||||
export function toggleScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeId: string
|
||||
): Permission[] {
|
||||
const existing = permissions.find((p) => p.key === key);
|
||||
const otherPerms = permissions.filter((p) => p.key !== key);
|
||||
|
||||
if (!existing) {
|
||||
return [...permissions, { key, scopes: [{ type: scopeType, id: scopeId }] }];
|
||||
}
|
||||
|
||||
const scopes = existing.scopes || [];
|
||||
const hasScope = scopes.some((s) => s.type === scopeType && s.id === scopeId);
|
||||
|
||||
if (hasScope) {
|
||||
const filtered = scopes.filter((s) => !(s.type === scopeType && s.id === scopeId));
|
||||
if (filtered.length === 0) {
|
||||
return otherPerms;
|
||||
}
|
||||
return [...otherPerms, { key, scopes: filtered }];
|
||||
}
|
||||
|
||||
return [...otherPerms, { key, scopes: [...scopes, { type: scopeType, id: scopeId }] }];
|
||||
}
|
||||
|
||||
export function mergePermissions(
|
||||
current: Permission[],
|
||||
updates: Permission[]
|
||||
): Permission[] {
|
||||
const result = new Map<string, Permission>();
|
||||
|
||||
for (const perm of current) {
|
||||
result.set(perm.key, perm);
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const existing = result.get(update.key);
|
||||
if (!existing) {
|
||||
result.set(update.key, update);
|
||||
} else if (!update.scopes || update.scopes.length === 0) {
|
||||
result.set(update.key, update);
|
||||
} else {
|
||||
const existingScopes = existing.scopes || [];
|
||||
const newScopes = new Map<string, PermissionScope>();
|
||||
|
||||
for (const scope of existingScopes) {
|
||||
newScopes.set(`${scope.type}:${scope.id}`, scope);
|
||||
}
|
||||
for (const scope of update.scopes) {
|
||||
newScopes.set(`${scope.type}:${scope.id}`, scope);
|
||||
}
|
||||
result.set(update.key, { key: update.key, scopes: Array.from(newScopes.values()) });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(result.values());
|
||||
}
|
||||
|
||||
export function calculatePermissionDiff(
|
||||
original: Permission[],
|
||||
updated: Permission[]
|
||||
): { added: Permission[]; removed: Permission[]; modified: Permission[] } {
|
||||
const added: Permission[] = [];
|
||||
const removed: Permission[] = [];
|
||||
const modified: Permission[] = [];
|
||||
|
||||
const originalKeys = new Set(original.map((p) => p.key));
|
||||
const updatedKeys = new Set(updated.map((p) => p.key));
|
||||
|
||||
for (const perm of updated) {
|
||||
if (!originalKeys.has(perm.key)) {
|
||||
added.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of original) {
|
||||
if (!updatedKeys.has(perm.key)) {
|
||||
removed.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of updated) {
|
||||
const orig = original.find((p) => p.key === perm.key);
|
||||
if (orig && JSON.stringify(orig.scopes) !== JSON.stringify(perm.scopes)) {
|
||||
modified.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed, modified };
|
||||
}
|
||||
|
||||
export function isPermissionFullyGlobal(permissions: Permission[], key: PermissionKey): boolean {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
return perm !== undefined && (!perm.scopes || perm.scopes.length === 0);
|
||||
}
|
||||
|
||||
export function getAllScopeIdsForPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType
|
||||
): string[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return [];
|
||||
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -925,6 +928,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10':
|
||||
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
@@ -3692,6 +3708,23 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
|
||||
Reference in New Issue
Block a user