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

@@ -4,7 +4,7 @@
// Projects Page (Target Channels) - Enhanced with Statistics
// ============================================================================
import { useEffect, useRef, useState } from "react";
import { useEffect, useCallback, useRef, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
@@ -19,6 +19,7 @@ import {
Info,
Bot,
Settings,
RefreshCw,
FolderKanban,
Archive,
RotateCcw,
@@ -60,6 +61,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
@@ -86,6 +88,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { handleApiError, showPermissionErrorToast } from "@/lib/utils/error-handler";
import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api";
@@ -143,48 +147,62 @@ const formatStatus = (status: string): string => {
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
const { currentWorkspace, workspaces, isAdmin, isWorkspaceAdmin, hasPermission, isDemoMode } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
// Local state for projects with proper include_archived support
const [localProjects, setLocalProjects] = useState<Project[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [localProjectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace();
// Show archived projects toggle - must be defined before loadProjects
const [showArchived, setShowArchived] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Poll projects list every 5s so newly added projects (via bot) appear automatically.
const refreshProjectsRef = useRef(refreshProjects);
// Load projects with include_archived support
const loadProjects = useCallback(async () => {
try {
setIsLoadingProjects(true);
if (isDemoMode) {
const demoProjectsRaw = (await import("@/lib/demo")).demoProjects;
const projects = showArchived
? demoProjectsRaw
: demoProjectsRaw.filter(p => p.status === "active");
setLocalProjects(projects);
} else {
const response = await projectsApi.list(workspaceId, { size: 100, include_archived: showArchived });
setLocalProjects(response.items);
}
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setIsLoadingProjects(false);
}
}, [workspaceId, showArchived, isDemoMode]);
// Manual refresh - checks for new projects
const handleRefresh = async () => {
setIsRefreshing(true);
await loadProjects();
setIsRefreshing(false);
};
// Initial load and when showArchived changes
useEffect(() => {
refreshProjectsRef.current = refreshProjects;
}, [refreshProjects]);
useEffect(() => {
if (!workspaceId) return;
if (isDemoMode) return;
const intervalId = window.setInterval(() => {
if (typeof document !== "undefined" && document.hidden) return;
void refreshProjectsRef.current();
}, 5000);
return () => {
window.clearInterval(intervalId);
};
}, [workspaceId, isDemoMode]);
loadProjects();
}, [loadProjects]);
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
@@ -194,69 +212,97 @@ export default function ProjectsPage() {
const handleSaveSettings = async () => {
if (!settingsProject) return;
try {
setIsUpdatingSettings(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setIsUpdatingSettings(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await loadProjects();
})(),
{
loading: "Сохранение настроек...",
success: () => {
setSettingsProject(null);
return "Настройки сохранены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить настройки";
},
}
);
};
const handleArchive = async () => {
if (!settingsProject) return;
try {
setIsArchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to archive project:", err);
} finally {
setIsArchiving(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Архивирование проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» архивирован`;
},
error: (err) => {
handleApiError(err);
return "Не удалось архивировать проект";
},
}
);
};
const handleUnarchive = async () => {
if (!settingsProject) return;
try {
setIsUnarchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to unarchive project:", err);
} finally {
setIsUnarchiving(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Восстановление проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось восстановить проект";
},
}
);
};
const handleDelete = async () => {
if (!settingsProject) return;
try {
setIsDeleting(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
} catch (err) {
console.error("Failed to delete project:", err);
} finally {
setIsDeleting(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Удаление проекта...",
success: () => {
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
return `Проект «${settingsProject.title}» удалён`;
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить проект";
},
}
);
};
const handleOpenDeleteDialog = () => {
@@ -312,20 +358,29 @@ export default function ProjectsPage() {
const handleMoveProject = async () => {
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
try {
setIsMoving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await refreshProjects();
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
} catch (err) {
console.error("Failed to move project:", err);
} finally {
setIsMoving(false);
}
const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await loadProjects();
})(),
{
loading: "Перемещение проекта...",
success: () => {
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
return `Проект перемещён в «${targetWorkspace?.name}»`;
},
error: (err) => {
handleApiError(err);
return "Не удалось переместить проект";
},
}
);
};
const getChannelAvatar = (project: Project) => {
@@ -335,17 +390,30 @@ export default function ProjectsPage() {
return null;
};
// Check if user has any analytics permission
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
// Check if user has subscriptions visibility
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
// Check if user can edit projects
const canEditProjects = hasPermission("projects_write");
// Load statistics for each project
useEffect(() => {
if (projects.length === 0) {
if (localProjects.length === 0) {
setProjectsWithStats([]);
return;
}
if (!canViewAnalytics) {
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
setLoadingStats(false);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
const statsPromises = projects.map(async (project) => {
const statsPromises = localProjects.map(async (project) => {
try {
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: project.id,
@@ -376,17 +444,23 @@ export default function ProjectsPage() {
setProjectsWithStats(results);
} catch (err) {
console.error("Failed to load project stats:", err);
setProjectsWithStats(projects);
setProjectsWithStats(localProjects);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [projects, workspaceId]);
}, [localProjects, workspaceId, canViewAnalytics]);
const isLoading = isLoadingProjects || loadingStats;
// Filter projects based on archived status (already filtered by API, but keep for safety)
const filteredProjects = localProjectsWithStats.filter((project) => {
if (showArchived) return true;
return project.status !== "archived";
});
return (
<TooltipProvider>
<DashboardHeader
@@ -467,11 +541,33 @@ export default function ProjectsPage() {
</AlertDescription>
</Alert>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
Показать архивированные
</Label>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoadingProjects || isRefreshing}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
Обновить
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projectsWithStats.length === 0 ? (
) : filteredProjects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
@@ -533,7 +629,7 @@ export default function ProjectsPage() {
</TableRow>
</TableHeader>
<TableBody>
{projectsWithStats.map((project) => (
{filteredProjects.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div className="flex items-center gap-3">
@@ -617,6 +713,7 @@ export default function ProjectsPage() {
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
>
<Settings className="h-3 w-3" />
</Button>
@@ -629,7 +726,7 @@ export default function ProjectsPage() {
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => (
{filteredProjects.map((project) => (
<Card key={project.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
@@ -747,6 +844,7 @@ export default function ProjectsPage() {
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
>
<Settings className="h-3.5 w-3.5" />
</Button>
@@ -792,47 +890,29 @@ export default function ProjectsPage() {
<Button
variant="outline"
onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
disabled={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
{isUnarchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
disabled={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</Button>
)}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
disabled={!isAdmin || !canEditProjects || isDemoMode}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
@@ -840,7 +920,7 @@ export default function ProjectsPage() {
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -858,16 +938,9 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
disabled={!canEditProjects || isDemoMode}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
Сохранить
</Button>
</DialogFooter>
</DialogContent>
@@ -897,17 +970,10 @@ export default function ProjectsPage() {
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -978,19 +1044,10 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
>
{isMoving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</Button>
</DialogFooter>
</DialogContent>