diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index cd03bcf4..e902b5e3 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState } from "react"; import { Inbox, ListTodo, @@ -12,11 +12,10 @@ import { Settings, LogOut, Plus, + Check, } from "lucide-react"; import { MulticaIcon } from "@multica/ui/components/multica-icon"; import { useAuth } from "../../lib/auth-context"; -import type { Workspace } from "@multica/types"; -import { api } from "../../lib/api"; const navItems = [ { href: "/inbox", label: "Inbox", icon: Inbox }, @@ -32,8 +31,33 @@ export default function DashboardLayout({ }) { const pathname = usePathname(); const router = useRouter(); - const { user, workspace, isLoading, logout } = useAuth(); + const { user, workspace, workspaces, isLoading, logout, switchWorkspace, createWorkspace } = useAuth(); const [showMenu, setShowMenu] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newName, setNewName] = useState(""); + const [newSlug, setNewSlug] = useState(""); + const [creating, setCreating] = useState(false); + + const handleNameChange = (value: string) => { + setNewName(value); + setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")); + }; + + const handleCreateWorkspace = async () => { + if (!newName.trim() || !newSlug.trim()) return; + setCreating(true); + try { + const ws = await createWorkspace({ name: newName.trim(), slug: newSlug.trim() }); + setShowCreateDialog(false); + setNewName(""); + setNewSlug(""); + await switchWorkspace(ws.id); + } catch (err) { + console.error("Failed to create workspace:", err); + } finally { + setCreating(false); + } + }; useEffect(() => { if (!isLoading && !user) { @@ -79,6 +103,40 @@ export default function DashboardLayout({ {user.email}
+
+ Workspaces +
+ {workspaces.map((ws) => ( + + ))} + +
setShowMenu(false)} @@ -148,6 +206,62 @@ export default function DashboardLayout({ {children}
+ + {/* Create Workspace Dialog */} + {showCreateDialog && ( + <> +
setShowCreateDialog(false)} + /> +
+
+

Create workspace

+

+ Create a new workspace for your team. +

+
+
+
+ + handleNameChange(e.target.value)} + placeholder="My Workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setNewSlug(e.target.value)} + placeholder="my-workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+
+ + +
+
+ + )}
); } diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx index 8d3d8e3b..cc918a35 100644 --- a/apps/web/lib/auth-context.test.tsx +++ b/apps/web/lib/auth-context.test.tsx @@ -17,6 +17,7 @@ const mockApi = vi.hoisted(() => ({ listWorkspaces: vi.fn(), listMembers: vi.fn(), listAgents: vi.fn(), + createWorkspace: vi.fn(), })); vi.mock("./api", () => ({ @@ -305,4 +306,134 @@ describe("AuthContext", () => { expect(result.current.user).toBeNull(); expect(localStorage.getItem("multica_token")).toBeNull(); }); + + it("initialization prefers stored workspace ID from list", async () => { + const mockWorkspace2: Workspace = { + id: "ws-2", + name: "Second WS", + slug: "second", + description: null, + settings: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + localStorage.setItem("multica_token", "stored-token"); + localStorage.setItem("multica_workspace_id", "ws-2"); + + mockApi.getMe.mockResolvedValueOnce(mockUser); + mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]); + mockApi.listMembers.mockResolvedValueOnce(mockMembers); + mockApi.listAgents.mockResolvedValueOnce(mockAgents); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.workspace).toEqual(mockWorkspace2); + expect(result.current.workspaces).toHaveLength(2); + }); + + it("initialization falls back to first workspace when stored ID not in list", async () => { + localStorage.setItem("multica_token", "stored-token"); + localStorage.setItem("multica_workspace_id", "ws-deleted"); + + mockApi.getMe.mockResolvedValueOnce(mockUser); + mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]); + mockApi.listMembers.mockResolvedValueOnce(mockMembers); + mockApi.listAgents.mockResolvedValueOnce(mockAgents); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.workspace).toEqual(mockWorkspace); + }); + + it("createWorkspace calls API and adds to workspaces list", async () => { + mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser }); + mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]); + mockApi.listMembers.mockResolvedValueOnce(mockMembers); + mockApi.listAgents.mockResolvedValueOnce(mockAgents); + + const newWs: Workspace = { + id: "ws-new", + name: "New WS", + slug: "new-ws", + description: null, + settings: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + mockApi.createWorkspace.mockResolvedValueOnce(newWs); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.login("test@multica.ai"); + }); + + let created: Workspace | undefined; + await act(async () => { + created = await result.current.createWorkspace({ name: "New WS", slug: "new-ws" }); + }); + + expect(mockApi.createWorkspace).toHaveBeenCalledWith({ name: "New WS", slug: "new-ws" }); + expect(created).toEqual(newWs); + expect(result.current.workspaces).toHaveLength(2); + expect(result.current.workspaces[1]).toEqual(newWs); + }); + + it("switchWorkspace updates context and calls setWorkspaceId", async () => { + const reloadMock = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...window.location, reload: reloadMock }, + writable: true, + }); + + const mockWorkspace2: Workspace = { + id: "ws-2", + name: "Second WS", + slug: "second", + description: null, + settings: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser }); + mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]); + mockApi.listMembers.mockResolvedValueOnce(mockMembers); + mockApi.listAgents.mockResolvedValueOnce(mockAgents); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.login("test@multica.ai"); + }); + + // Setup mocks for the switch + mockApi.listMembers.mockResolvedValueOnce([]); + mockApi.listAgents.mockResolvedValueOnce([]); + + await act(async () => { + await result.current.switchWorkspace("ws-2"); + }); + + expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2"); + expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2"); + expect(reloadMock).toHaveBeenCalled(); + }); }); diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx index 72eb2805..18333b6e 100644 --- a/apps/web/lib/auth-context.tsx +++ b/apps/web/lib/auth-context.tsx @@ -15,11 +15,14 @@ import { api } from "./api"; interface AuthContextValue { user: User | null; workspace: Workspace | null; + workspaces: Workspace[]; members: MemberWithUser[]; agents: Agent[]; isLoading: boolean; login: (email: string, name?: string) => Promise; logout: () => void; + switchWorkspace: (workspaceId: string) => Promise; + createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise; updateWorkspace: (ws: Workspace) => void; refreshMembers: () => Promise; refreshAgents: () => Promise; @@ -35,6 +38,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [workspace, setWorkspace] = useState(null); const [members, setMembers] = useState([]); + const [workspaces, setWorkspaces] = useState([]); const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(true); const router = useRouter(); @@ -56,9 +60,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { const me = await api.getMe(); setUser(me); - const workspaces = await api.listWorkspaces(); - if (workspaces.length > 0) { - const ws = workspaces[0]!; + const wsList = await api.listWorkspaces(); + setWorkspaces(wsList); + if (wsList.length > 0) { + const stored = wsId ? wsList.find(w => w.id === wsId) : null; + const ws = stored ?? wsList[0]!; setWorkspace(ws); api.setWorkspaceId(ws.id); localStorage.setItem("multica_workspace_id", ws.id); @@ -87,9 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(u); // Load workspace - const workspaces = await api.listWorkspaces(); - if (workspaces.length > 0) { - const ws = workspaces[0]!; + const wsList = await api.listWorkspaces(); + setWorkspaces(wsList); + if (wsList.length > 0) { + const ws = wsList[0]!; setWorkspace(ws); api.setWorkspaceId(ws.id); localStorage.setItem("multica_workspace_id", ws.id); @@ -110,11 +117,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.removeItem("multica_workspace_id"); setUser(null); setWorkspace(null); + setWorkspaces([]); setMembers([]); setAgents([]); router.push("/login"); }, [router]); + const switchWorkspace = useCallback(async (workspaceId: string) => { + const ws = workspaces.find(w => w.id === workspaceId); + if (!ws) return; + + api.setWorkspaceId(ws.id); + localStorage.setItem("multica_workspace_id", ws.id); + setWorkspace(ws); + + const [m, a] = await Promise.all([ + api.listMembers(ws.id), + api.listAgents({ workspace_id: ws.id }), + ]); + setMembers(m); + setAgents(a); + + window.location.reload(); + }, [workspaces]); + + const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => { + const ws = await api.createWorkspace(data); + setWorkspaces(prev => [...prev, ws]); + return ws; + }, []); + const updateWorkspaceState = useCallback((ws: Workspace) => { setWorkspace(ws); }, []); @@ -174,11 +206,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { value={{ user, workspace, + workspaces, members, agents, isLoading, login, logout, + switchWorkspace, + createWorkspace: createNewWorkspace, updateWorkspace: updateWorkspaceState, refreshMembers, refreshAgents,