524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
"use client";
|
|
|
|
// ============================================================================
|
|
// Workspace Provider
|
|
// ============================================================================
|
|
|
|
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
} from "react";
|
|
import { useParams, useRouter, usePathname } from "next/navigation";
|
|
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
|
|
import type {
|
|
Workspace,
|
|
Project,
|
|
Permission,
|
|
PermissionKey,
|
|
} from "@/lib/types/api";
|
|
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
|
import { isDemoWorkspace, demoApi, demoWorkspace, demoProjects, DEMO_WORKSPACE_ID } from "@/lib/demo";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface WorkspaceContextType {
|
|
// Workspaces
|
|
workspaces: Workspace[];
|
|
currentWorkspace: Workspace | null;
|
|
setCurrentWorkspace: (workspace: Workspace) => void;
|
|
|
|
// Projects (Target Channels)
|
|
projects: Project[];
|
|
selectedProjects: Project[];
|
|
currentProject: Project | null; // First selected or null (for backward compatibility)
|
|
setSelectedProjects: (projects: Project[]) => void;
|
|
toggleProject: (project: Project) => void;
|
|
selectAllProjects: () => void;
|
|
clearProjectSelection: () => void;
|
|
|
|
// Helper for API filtering
|
|
// Returns project_id only if single project selected, undefined otherwise
|
|
getProjectFilterId: () => string | undefined;
|
|
// Returns true if all projects are selected
|
|
allProjectsSelected: boolean;
|
|
|
|
// Demo mode
|
|
isDemoMode: boolean;
|
|
|
|
// Permissions
|
|
permissions: Permission[];
|
|
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
|
|
isAdmin: boolean;
|
|
// Check if user is admin in a specific workspace
|
|
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
|
|
|
// Loading states
|
|
isLoading: boolean;
|
|
isLoadingProjects: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
refreshWorkspaces: () => Promise<Workspace[]>;
|
|
refreshProjects: () => Promise<void>;
|
|
createWorkspace: (name: string, avatarFile?: File | null) => Promise<Workspace>;
|
|
}
|
|
|
|
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
|
|
undefined
|
|
);
|
|
|
|
// ============================================================================
|
|
// Hook
|
|
// ============================================================================
|
|
|
|
export const useWorkspace = () => {
|
|
const context = useContext(WorkspaceContext);
|
|
if (!context) {
|
|
throw new Error("useWorkspace must be used within WorkspaceProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Provider
|
|
// ============================================================================
|
|
|
|
interface WorkspaceProviderProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
|
children,
|
|
}) => {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const { user } = useAuth();
|
|
|
|
// State
|
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [selectedProjects, setSelectedProjectsState] = useState<Project[]>([]);
|
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Get workspaceId from URL params
|
|
const workspaceIdFromUrl = params?.workspaceId as string | undefined;
|
|
|
|
// Check if demo mode
|
|
const isDemoMode = isDemoWorkspace(workspaceIdFromUrl);
|
|
|
|
// Current project (first selected for backward compatibility)
|
|
const currentProject = selectedProjects.length > 0 ? selectedProjects[0] : null;
|
|
|
|
// Check if all projects are selected
|
|
const allProjectsSelected =
|
|
projects.length > 0 && selectedProjects.length === projects.length;
|
|
|
|
// Get project_id for API filtering
|
|
// Returns undefined if multiple projects selected (to get workspace-wide data)
|
|
const getProjectFilterId = useCallback(() => {
|
|
if (selectedProjects.length === 1) {
|
|
return selectedProjects[0].id;
|
|
}
|
|
// Multiple projects selected - return undefined to get all data
|
|
return undefined;
|
|
}, [selectedProjects]);
|
|
|
|
// ============================================================================
|
|
// Permission Helpers
|
|
// ============================================================================
|
|
|
|
const isAdmin = permissions.some((p) => p.key === "admin_full");
|
|
|
|
const hasPermission = useCallback(
|
|
(key: PermissionKey, projectId?: string): boolean => {
|
|
// Admin has all permissions
|
|
if (isAdmin) return true;
|
|
|
|
// Find permission by key
|
|
const permission = permissions.find((p) => p.key === key);
|
|
if (!permission) return false;
|
|
|
|
// If no scopes defined, permission applies globally
|
|
if (!permission.scopes || permission.scopes.length === 0) return true;
|
|
|
|
// If projectId provided, check if it's in scopes
|
|
if (projectId) {
|
|
return permission.scopes.some(
|
|
(s) => s.type === "project" && s.id === projectId
|
|
);
|
|
}
|
|
|
|
// Permission exists but is scoped - need projectId to verify
|
|
return true;
|
|
},
|
|
[permissions, isAdmin]
|
|
);
|
|
|
|
// ============================================================================
|
|
// Load Workspaces
|
|
// ============================================================================
|
|
|
|
const loadWorkspaces = useCallback(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// Demo mode - use mock data, but also load real workspaces if user is logged in
|
|
if (isDemoMode) {
|
|
const allWorkspaces: Workspace[] = [demoWorkspace];
|
|
|
|
// If user is logged in, also load their real workspaces
|
|
if (user) {
|
|
try {
|
|
const response = await workspacesApi.list({ size: 100 });
|
|
allWorkspaces.push(...response.items);
|
|
} catch (err) {
|
|
// If loading real workspaces fails, just use demo workspace
|
|
console.warn("Failed to load real workspaces in demo mode:", err);
|
|
}
|
|
}
|
|
|
|
setWorkspaces(allWorkspaces);
|
|
setCurrentWorkspaceState(demoWorkspace);
|
|
setIsLoading(false);
|
|
return allWorkspaces;
|
|
}
|
|
|
|
const response = await workspacesApi.list({ size: 100 });
|
|
setWorkspaces(response.items);
|
|
|
|
// If we have workspaceId in URL, select that workspace
|
|
if (workspaceIdFromUrl && response.items.length > 0) {
|
|
const workspace = response.items.find((w) => w.id === workspaceIdFromUrl);
|
|
if (workspace) {
|
|
setCurrentWorkspaceState(workspace);
|
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
|
} else {
|
|
// Workspace from URL not found, redirect to first available
|
|
const firstWorkspace = response.items[0];
|
|
setCurrentWorkspaceState(firstWorkspace);
|
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, firstWorkspace.id);
|
|
// Redirect to correct workspace
|
|
const newPath = pathname.replace(
|
|
`/dashboard/${workspaceIdFromUrl}`,
|
|
`/dashboard/${firstWorkspace.id}`
|
|
);
|
|
router.replace(newPath);
|
|
}
|
|
} else if (response.items.length > 0) {
|
|
// Try to restore from localStorage or use first
|
|
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
|
|
const saved = savedId
|
|
? response.items.find((w) => w.id === savedId)
|
|
: null;
|
|
const workspace = saved || response.items[0];
|
|
setCurrentWorkspaceState(workspace);
|
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
|
}
|
|
|
|
return response.items;
|
|
} catch (err: any) {
|
|
console.error("Failed to load workspaces:", err);
|
|
setError(err?.error?.message || "Ошибка загрузки воркспейсов");
|
|
return [];
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [workspaceIdFromUrl, pathname, router, isDemoMode, user]);
|
|
|
|
// ============================================================================
|
|
// Load Projects & Permissions
|
|
// ============================================================================
|
|
|
|
const loadProjectsAndPermissions = useCallback(async (workspaceId: string) => {
|
|
try {
|
|
setIsLoadingProjects(true);
|
|
|
|
// Demo mode - use mock data
|
|
if (isDemoMode) {
|
|
const activeProjects = demoProjects.filter((p) => p.status === "active");
|
|
setProjects(activeProjects);
|
|
setPermissions([{ key: "admin_full" }]);
|
|
setSelectedProjectsState(activeProjects.length > 0 ? [activeProjects[0]] : []);
|
|
setIsLoadingProjects(false);
|
|
return;
|
|
}
|
|
|
|
// Load projects
|
|
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
|
|
const activeProjects = projectsResponse.items.filter(
|
|
(p) => p.status === "active"
|
|
);
|
|
setProjects(activeProjects);
|
|
|
|
// Load current user's permissions
|
|
const me = await authApi.me();
|
|
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
|
|
// Find member by user.id (not by member record id)
|
|
const currentMember = membersResponse.items.find((m) => m.user.id === me.id);
|
|
|
|
if (currentMember) {
|
|
// If member found but no permissions, they're likely the creator - grant admin
|
|
if (currentMember.permissions.length === 0) {
|
|
setPermissions([{ key: "admin_full" }]);
|
|
} else {
|
|
setPermissions(currentMember.permissions);
|
|
}
|
|
} else {
|
|
// User not in members list - might be creator, check if only workspace
|
|
// Grant admin access by default for workspace creators
|
|
if (membersResponse.items.length === 0) {
|
|
setPermissions([{ key: "admin_full" }]);
|
|
} else {
|
|
setPermissions([]);
|
|
}
|
|
}
|
|
|
|
// Restore selected projects from localStorage
|
|
const savedProjectIds = localStorage.getItem(
|
|
`${STORAGE_KEYS.SELECTED_PROJECT}_${workspaceId}`
|
|
);
|
|
if (savedProjectIds && activeProjects.length > 0) {
|
|
try {
|
|
const ids = JSON.parse(savedProjectIds) as string[];
|
|
const savedProjects = activeProjects.filter((p) => ids.includes(p.id));
|
|
if (savedProjects.length > 0) {
|
|
setSelectedProjectsState(savedProjects);
|
|
} else if (activeProjects.length > 0) {
|
|
setSelectedProjectsState([activeProjects[0]]);
|
|
}
|
|
} catch {
|
|
// Old format (single id), migrate to array
|
|
const savedProject = activeProjects.find((p) => p.id === savedProjectIds);
|
|
if (savedProject) {
|
|
setSelectedProjectsState([savedProject]);
|
|
} else if (activeProjects.length > 0) {
|
|
setSelectedProjectsState([activeProjects[0]]);
|
|
}
|
|
}
|
|
} else if (activeProjects.length > 0) {
|
|
setSelectedProjectsState([activeProjects[0]]);
|
|
} else {
|
|
setSelectedProjectsState([]);
|
|
}
|
|
} catch (err: any) {
|
|
console.error("Failed to load projects/permissions:", err);
|
|
} finally {
|
|
setIsLoadingProjects(false);
|
|
}
|
|
}, [isDemoMode]);
|
|
|
|
// ============================================================================
|
|
// Set Current Workspace
|
|
// ============================================================================
|
|
|
|
const setCurrentWorkspace = useCallback(
|
|
(workspace: Workspace) => {
|
|
setCurrentWorkspaceState(workspace);
|
|
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
|
|
|
|
// Update URL if we're in dashboard
|
|
if (pathname.startsWith("/dashboard/")) {
|
|
const currentWorkspaceId = workspaceIdFromUrl;
|
|
if (currentWorkspaceId && currentWorkspaceId !== workspace.id) {
|
|
const newPath = pathname.replace(
|
|
`/dashboard/${currentWorkspaceId}`,
|
|
`/dashboard/${workspace.id}`
|
|
);
|
|
router.push(newPath);
|
|
}
|
|
} else if (pathname === "/dashboard" || pathname === "/") {
|
|
router.push(`/dashboard/${workspace.id}`);
|
|
}
|
|
|
|
// Load projects for new workspace
|
|
loadProjectsAndPermissions(workspace.id);
|
|
},
|
|
[pathname, router, workspaceIdFromUrl, loadProjectsAndPermissions]
|
|
);
|
|
|
|
// ============================================================================
|
|
// Project Selection Methods
|
|
// ============================================================================
|
|
|
|
const saveSelectedProjects = useCallback(
|
|
(selected: Project[]) => {
|
|
if (currentWorkspace) {
|
|
localStorage.setItem(
|
|
`${STORAGE_KEYS.SELECTED_PROJECT}_${currentWorkspace.id}`,
|
|
JSON.stringify(selected.map((p) => p.id))
|
|
);
|
|
}
|
|
},
|
|
[currentWorkspace]
|
|
);
|
|
|
|
const setSelectedProjects = useCallback(
|
|
(selected: Project[]) => {
|
|
setSelectedProjectsState(selected);
|
|
saveSelectedProjects(selected);
|
|
},
|
|
[saveSelectedProjects]
|
|
);
|
|
|
|
const toggleProject = useCallback(
|
|
(project: Project) => {
|
|
setSelectedProjectsState((prev) => {
|
|
const isSelected = prev.some((p) => p.id === project.id);
|
|
let newSelection: Project[];
|
|
|
|
if (isSelected) {
|
|
// Don't allow deselecting if it's the only one
|
|
if (prev.length <= 1) {
|
|
return prev;
|
|
}
|
|
newSelection = prev.filter((p) => p.id !== project.id);
|
|
} else {
|
|
newSelection = [...prev, project];
|
|
}
|
|
|
|
saveSelectedProjects(newSelection);
|
|
return newSelection;
|
|
});
|
|
},
|
|
[saveSelectedProjects]
|
|
);
|
|
|
|
const selectAllProjects = useCallback(() => {
|
|
setSelectedProjectsState(projects);
|
|
saveSelectedProjects(projects);
|
|
}, [projects, saveSelectedProjects]);
|
|
|
|
const clearProjectSelection = useCallback(() => {
|
|
if (projects.length > 0) {
|
|
const firstProject = [projects[0]];
|
|
setSelectedProjectsState(firstProject);
|
|
saveSelectedProjects(firstProject);
|
|
}
|
|
}, [projects, saveSelectedProjects]);
|
|
|
|
// ============================================================================
|
|
// Check if user is admin in a specific workspace
|
|
// ============================================================================
|
|
|
|
const isWorkspaceAdmin = useCallback(async (workspaceId: string): Promise<boolean> => {
|
|
// Demo mode - user is admin
|
|
if (isDemoMode || workspaceId === DEMO_WORKSPACE_ID) {
|
|
return true;
|
|
}
|
|
|
|
// Check if this is the current workspace
|
|
if (currentWorkspace?.id === workspaceId) {
|
|
return isAdmin;
|
|
}
|
|
|
|
// Load permissions for the target workspace
|
|
try {
|
|
const me = await authApi.me();
|
|
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
|
|
const member = membersResponse.items.find((m) => m.user.id === me.id);
|
|
|
|
if (!member) {
|
|
// Check if user is creator (members list is empty or this is first member)
|
|
if (membersResponse.items.length === 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Check if has admin_full permission
|
|
return member.permissions.some((p) => p.key === "admin_full");
|
|
} catch (err) {
|
|
console.error("Failed to check workspace permissions:", err);
|
|
return false;
|
|
}
|
|
}, [currentWorkspace, isAdmin, isDemoMode]);
|
|
|
|
// ============================================================================
|
|
// Create Workspace
|
|
// ============================================================================
|
|
|
|
const createWorkspace = useCallback(async (name: string, avatarFile?: File | null): Promise<Workspace> => {
|
|
const workspace = await workspacesApi.create({ name, avatar_file: avatarFile });
|
|
setWorkspaces((prev) => [...prev, workspace]);
|
|
return workspace;
|
|
}, []);
|
|
|
|
// ============================================================================
|
|
// Effects
|
|
// ============================================================================
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadWorkspaces();
|
|
}, [loadWorkspaces]);
|
|
|
|
// Load projects when workspace changes
|
|
useEffect(() => {
|
|
if (currentWorkspace) {
|
|
loadProjectsAndPermissions(currentWorkspace.id);
|
|
}
|
|
}, [currentWorkspace?.id, loadProjectsAndPermissions]);
|
|
|
|
// ============================================================================
|
|
// Render
|
|
// ============================================================================
|
|
|
|
return (
|
|
<WorkspaceContext.Provider
|
|
value={{
|
|
// Workspaces
|
|
workspaces,
|
|
currentWorkspace,
|
|
setCurrentWorkspace,
|
|
|
|
// Projects
|
|
projects,
|
|
selectedProjects,
|
|
currentProject,
|
|
setSelectedProjects,
|
|
toggleProject,
|
|
selectAllProjects,
|
|
clearProjectSelection,
|
|
getProjectFilterId,
|
|
allProjectsSelected,
|
|
|
|
// Demo mode
|
|
isDemoMode,
|
|
|
|
// Permissions
|
|
permissions,
|
|
hasPermission,
|
|
isAdmin,
|
|
isWorkspaceAdmin,
|
|
|
|
// Loading states
|
|
isLoading,
|
|
isLoadingProjects,
|
|
error,
|
|
|
|
// Actions
|
|
refreshWorkspaces: loadWorkspaces,
|
|
refreshProjects: () =>
|
|
currentWorkspace
|
|
? loadProjectsAndPermissions(currentWorkspace.id)
|
|
: Promise.resolve(),
|
|
createWorkspace,
|
|
}}
|
|
>
|
|
{children}
|
|
</WorkspaceContext.Provider>
|
|
);
|
|
};
|