-
Create New Team
-
- Add a team to organize members and permissions.
-
+
+
+
+
+
Teams
+ {teams.length} total
-
+
+
+
+ {isRefreshing ? "Refreshing..." : "Refresh"}
+
+
+
- {isCreateDialogOpen && (
-
-
-
Create New Team
-
- Enter a team name and optional description.
+
+
+ ← Back to Settings
+
+
+
+ {error ? (
+
+
+
+ {error}
+
+
+ ) : null}
-
-
-
+
+
{team.name}
+
+ {description && description.length > 0 ? description : "No description"}
+
+
+
+
+
+
+ {toMemberLabel(memberCount)}
+
+
+ Created {new Date(team.createdAt).toLocaleDateString()}
+
+
+
+
+ );
+ })}
+
+
)}
-
-
- Your Teams ({isLoading ? "..." : teams.length})
-
- {loadError !== null ? (
-
- {loadError}
-
- ) : isLoading ? (
-
- Loading teams...
-
- ) : teams.length === 0 ? (
-
-
-
+ Cancel
+
+
+
+
+
+
+
+
{
+ if (!open && !isDeleting) {
+ setDeleteTarget(null);
+ }
+ }}
+ >
+
+
+ Delete Team
+
+ Delete {deleteTarget?.name}? Team members will be removed from this team assignment.
+
+
+
+ Cancel
+ {
+ void confirmDelete();
+ }}
+ >
+ {isDeleting ? "Deleting..." : "Delete Team"}
+
+
+
+
+
);
}
diff --git a/apps/web/src/app/(authenticated)/settings/users/page.test.tsx b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx
index e65167e..8568748 100644
--- a/apps/web/src/app/(authenticated)/settings/users/page.test.tsx
+++ b/apps/web/src/app/(authenticated)/settings/users/page.test.tsx
@@ -118,6 +118,23 @@ describe("UsersSettingsPage", () => {
});
});
+ it("shows access denied to non-admin users", async () => {
+ fetchUserWorkspacesMock.mockResolvedValueOnce([
+ {
+ id: "workspace-1",
+ name: "Personal Workspace",
+ ownerId: "owner-1",
+ role: WorkspaceMemberRole.MEMBER,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ },
+ ]);
+
+ render(
);
+
+ expect(await screen.findByText("Access Denied")).toBeInTheDocument();
+ expect(fetchAdminUsersMock).not.toHaveBeenCalled();
+ });
+
it("invites a user with email and role from the dialog", async () => {
const user = userEvent.setup();
render(
);
diff --git a/apps/web/src/app/(authenticated)/settings/users/page.tsx b/apps/web/src/app/(authenticated)/settings/users/page.tsx
index bc3bde8..0b35130 100644
--- a/apps/web/src/app/(authenticated)/settings/users/page.tsx
+++ b/apps/web/src/app/(authenticated)/settings/users/page.tsx
@@ -56,6 +56,7 @@ import {
type UpdateUserDto,
} from "@/lib/api/admin";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
+import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
const ROLE_PRIORITY: Record
= {
[WorkspaceMemberRole.OWNER]: 4,
@@ -146,10 +147,6 @@ export default function UsersSettingsPage(): ReactElement {
}
}, []);
- useEffect(() => {
- void loadUsers(true);
- }, [loadUsers]);
-
useEffect(() => {
fetchUserWorkspaces()
.then((workspaces) => {
@@ -167,6 +164,14 @@ export default function UsersSettingsPage(): ReactElement {
});
}, []);
+ useEffect(() => {
+ if (isAdmin !== true) {
+ return;
+ }
+
+ void loadUsers(true);
+ }, [isAdmin, loadUsers]);
+
function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null);
@@ -332,17 +337,20 @@ export default function UsersSettingsPage(): ReactElement {
}
}
- if (isAdmin === false) {
+ if (isAdmin === null) {
return (
-
-
-
Access Denied
-
You need Admin or Owner role to manage users.
-
-
+
+
+ Checking permissions...
+
+
);
}
+ if (!isAdmin) {
+ return ;
+ }
+
return (
diff --git a/apps/web/src/components/settings/SettingsAccessDenied.tsx b/apps/web/src/components/settings/SettingsAccessDenied.tsx
new file mode 100644
index 0000000..b72ddb8
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsAccessDenied.tsx
@@ -0,0 +1,16 @@
+import type { ReactElement } from "react";
+
+interface SettingsAccessDeniedProps {
+ message: string;
+}
+
+export function SettingsAccessDenied({ message }: SettingsAccessDeniedProps): ReactElement {
+ return (
+
+
+
Access Denied
+
{message}
+
+
+ );
+}
diff --git a/apps/web/src/lib/api/teams.test.ts b/apps/web/src/lib/api/teams.test.ts
index dff229a..14340a8 100644
--- a/apps/web/src/lib/api/teams.test.ts
+++ b/apps/web/src/lib/api/teams.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client";
-import { fetchTeams, createTeam, fetchTeamMembers } from "./teams";
+import { fetchTeams, createTeam, fetchTeamMembers, updateTeam, deleteTeam } from "./teams";
vi.mock("./client");
@@ -44,6 +44,18 @@ describe("createTeam", (): void => {
});
});
+describe("updateTeam", (): void => {
+ it("patches team endpoint", async (): Promise
=> {
+ vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "t1", name: "Platform" } as never);
+ await updateTeam("t1", { name: "Platform" });
+ expect(client.apiPatch).toHaveBeenCalledWith(
+ "/api/workspaces/ws-1/teams/t1",
+ expect.objectContaining({ name: "Platform" }),
+ "ws-1"
+ );
+ });
+});
+
describe("fetchTeamMembers", (): void => {
it("calls members endpoint for team", async (): Promise => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
@@ -51,3 +63,11 @@ describe("fetchTeamMembers", (): void => {
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
});
});
+
+describe("deleteTeam", (): void => {
+ it("deletes team endpoint", async (): Promise => {
+ vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
+ await deleteTeam("t1");
+ expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t1", "ws-1");
+ });
+});
diff --git a/apps/web/src/lib/api/teams.ts b/apps/web/src/lib/api/teams.ts
index 1127e98..2fcac4d 100644
--- a/apps/web/src/lib/api/teams.ts
+++ b/apps/web/src/lib/api/teams.ts
@@ -4,7 +4,7 @@
*/
import type { TeamMemberRole } from "@mosaic/shared";
-import { apiDelete, apiGet, apiPost } from "./client";
+import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
@@ -55,6 +55,11 @@ export interface CreateTeamDto {
description?: string;
}
+export interface UpdateTeamDto {
+ name?: string;
+ description?: string | null;
+}
+
export interface AddTeamMemberDto {
userId: string;
role?: TeamMemberRole;
@@ -80,6 +85,22 @@ export async function createTeam(dto: CreateTeamDto, workspaceId?: string): Prom
);
}
+/**
+ * Update a team in the active workspace.
+ */
+export async function updateTeam(
+ teamId: string,
+ dto: UpdateTeamDto,
+ workspaceId?: string
+): Promise {
+ const resolvedWorkspaceId = resolveWorkspaceId(workspaceId);
+ return apiPatch(
+ `/api/workspaces/${resolvedWorkspaceId}/teams/${teamId}`,
+ dto,
+ resolvedWorkspaceId
+ );
+}
+
/**
* Fetch team members for a team in the active workspace.
* The current backend route shape is workspace-scoped team membership.