From 988e17c8f1c3169e3a66b61ed2a82be6cdde3278 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 14:44:34 -0600 Subject: [PATCH] feat(web): add admin users settings page (MS21-UI-001) --- .mosaic/orchestrator/mission.json | 10 + .mosaic/orchestrator/session.lock | 8 +- .../src/app/(authenticated)/settings/page.tsx | 25 + .../(authenticated)/settings/users/page.tsx | 435 ++++++++++++++++++ apps/web/src/lib/api/admin.ts | 98 ++++ apps/web/src/lib/api/index.ts | 1 + 6 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/settings/users/page.tsx create mode 100644 apps/web/src/lib/api/admin.ts diff --git a/.mosaic/orchestrator/mission.json b/.mosaic/orchestrator/mission.json index cd0daa7..45fc5a4 100644 --- a/.mosaic/orchestrator/mission.json +++ b/.mosaic/orchestrator/mission.json @@ -75,6 +75,16 @@ "milestone_at_end": "", "tasks_completed": [], "last_task_id": "" + }, + { + "session_id": "sess-002", + "runtime": "unknown", + "started_at": "2026-02-28T20:30:13Z", + "ended_at": "", + "ended_reason": "", + "milestone_at_end": "", + "tasks_completed": [], + "last_task_id": "" } ] } diff --git a/.mosaic/orchestrator/session.lock b/.mosaic/orchestrator/session.lock index f283e17..cfdae6f 100644 --- a/.mosaic/orchestrator/session.lock +++ b/.mosaic/orchestrator/session.lock @@ -1,8 +1,8 @@ { - "session_id": "sess-001", + "session_id": "sess-002", "runtime": "unknown", - "pid": 2396592, - "started_at": "2026-02-28T17:48:51Z", - "project_path": "/tmp/ms21-api-003", + "pid": 3178395, + "started_at": "2026-02-28T20:30:13Z", + "project_path": "/tmp/ms21-ui-001", "milestone_id": "" } diff --git a/apps/web/src/app/(authenticated)/settings/page.tsx b/apps/web/src/app/(authenticated)/settings/page.tsx index 48645c9..1138c01 100644 --- a/apps/web/src/app/(authenticated)/settings/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/page.tsx @@ -196,6 +196,31 @@ const categories: CategoryConfig[] = [ ), }, + { + title: "Users", + description: "Invite, manage roles, and deactivate users across your workspaces.", + href: "/settings/users", + accent: "var(--ms-green-400)", + iconBg: "rgba(34, 197, 94, 0.12)", + icon: ( + + ), + }, { title: "Workspaces", description: diff --git a/apps/web/src/app/(authenticated)/settings/users/page.tsx b/apps/web/src/app/(authenticated)/settings/users/page.tsx new file mode 100644 index 0000000..45397bb --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/users/page.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, + type ChangeEvent, + type ReactElement, + type SyntheticEvent, +} from "react"; +import Link from "next/link"; +import { UserPlus, UserX } from "lucide-react"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + deactivateUser, + fetchAdminUsers, + inviteUser, + type AdminUser, + type AdminUsersResponse, + type InviteUserDto, +} from "@/lib/api/admin"; + +const ROLE_PRIORITY: Record = { + [WorkspaceMemberRole.OWNER]: 4, + [WorkspaceMemberRole.ADMIN]: 3, + [WorkspaceMemberRole.MEMBER]: 2, + [WorkspaceMemberRole.GUEST]: 1, +}; + +const INITIAL_INVITE_FORM = { + email: "", + name: "", + workspaceId: "", + role: WorkspaceMemberRole.MEMBER, +}; + +function toRoleLabel(role: WorkspaceMemberRole): string { + return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; +} + +function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null { + const [firstMembership, ...restMemberships] = user.workspaceMemberships; + if (!firstMembership) { + return null; + } + + return restMemberships.reduce((highest, membership) => { + if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) { + return membership.role; + } + return highest; + }, firstMembership.role); +} + +export default function UsersSettingsPage(): ReactElement { + const [users, setUsers] = useState([]); + const [meta, setMeta] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [isInviteOpen, setIsInviteOpen] = useState(false); + const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM); + const [inviteError, setInviteError] = useState(null); + const [isInviting, setIsInviting] = useState(false); + + const [deactivateTarget, setDeactivateTarget] = useState(null); + const [isDeactivating, setIsDeactivating] = useState(false); + + const loadUsers = useCallback(async (showLoadingState: boolean): Promise => { + try { + if (showLoadingState) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + const response = await fetchAdminUsers(1, 50); + setUsers(response.data); + setMeta(response.meta); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load admin users"); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + void loadUsers(true); + }, [loadUsers]); + + function resetInviteForm(): void { + setInviteForm(INITIAL_INVITE_FORM); + setInviteError(null); + } + + function handleInviteOpenChange(open: boolean): void { + if (!open && !isInviting) { + resetInviteForm(); + } + setIsInviteOpen(open); + } + + async function handleInviteSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setInviteError(null); + + const email = inviteForm.email.trim(); + if (!email) { + setInviteError("Email is required."); + return; + } + + const dto: InviteUserDto = { email }; + + const name = inviteForm.name.trim(); + if (name) { + dto.name = name; + } + + const workspaceId = inviteForm.workspaceId.trim(); + if (workspaceId) { + dto.workspaceId = workspaceId; + dto.role = inviteForm.role; + } + + try { + setIsInviting(true); + await inviteUser(dto); + setIsInviteOpen(false); + resetInviteForm(); + await loadUsers(false); + } catch (err: unknown) { + setInviteError(err instanceof Error ? err.message : "Failed to invite user"); + } finally { + setIsInviting(false); + } + } + + async function confirmDeactivate(): Promise { + if (!deactivateTarget) { + return; + } + + try { + setIsDeactivating(true); + await deactivateUser(deactivateTarget.id); + setDeactivateTarget(null); + await loadUsers(false); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to deactivate user"); + } finally { + setIsDeactivating(false); + } + } + + return ( +
+
+
+
+

Users

+ {meta ? {meta.total} total : null} +
+

Invite and manage workspace users

+
+ +
+ + + + + + + + + Invite User + + Create an invited account and optionally assign workspace access. + + + +
{ + void handleInviteSubmit(e); + }} + className="space-y-4" + > +
+ + ) => { + setInviteForm((prev) => ({ ...prev, email: e.target.value })); + }} + placeholder="user@example.com" + maxLength={255} + required + /> +
+ +
+ + ) => { + setInviteForm((prev) => ({ ...prev, name: e.target.value })); + }} + placeholder="Jane Doe" + maxLength={255} + /> +
+ +
+ + ) => { + setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value })); + }} + placeholder="UUID workspace id" + /> +
+ +
+ + +

+ Role is only applied when workspace ID is provided. +

+
+ + {inviteError ? ( +

+ {inviteError} +

+ ) : null} + + + + + +
+
+
+
+
+ +
+ + ← Back to Settings + +
+ + {error ? ( + + +

+ {error} +

+
+
+ ) : null} + + {isLoading ? ( + + + Loading users... + + + ) : users.length === 0 ? ( + + + No Users Yet + Invite the first user to get started. + + + ) : ( + + + User Directory + Name, email, role, and account status. + + + {users.map((user) => { + const primaryRole = getPrimaryRole(user); + const isActive = user.deactivatedAt === null; + + return ( +
+
+

{user.name || "Unnamed User"}

+

{user.email}

+
+ +
+ + {primaryRole ? toRoleLabel(primaryRole) : "No role"} + + + {isActive ? "Active" : "Inactive"} + + {isActive ? ( + + ) : null} +
+
+ ); + })} +
+
+ )} + + { + if (!open && !isDeactivating) { + setDeactivateTarget(null); + } + }} + > + + + Deactivate User + + Deactivate {deactivateTarget?.email}? They will no longer be able to access the + system. + + + + Cancel + { + void confirmDeactivate(); + }} + > + {isDeactivating ? "Deactivating..." : "Deactivate"} + + + + +
+ ); +} diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts new file mode 100644 index 0000000..cb032ac --- /dev/null +++ b/apps/web/src/lib/api/admin.ts @@ -0,0 +1,98 @@ +/** + * Admin API Client + * Handles admin user management requests + */ + +import type { WorkspaceMemberRole } from "@mosaic/shared"; +import { apiGet, apiPatch, apiPost, apiDelete } from "./client"; + +export interface AdminWorkspaceMembership { + workspaceId: string; + workspaceName: string; + role: WorkspaceMemberRole; + joinedAt: string; +} + +export interface AdminUser { + id: string; + name: string; + email: string; + emailVerified: boolean; + image: string | null; + createdAt: string; + deactivatedAt: string | null; + isLocalAuth: boolean; + invitedAt: string | null; + invitedBy: string | null; + workspaceMemberships: AdminWorkspaceMembership[]; +} + +export interface AdminUsersResponse { + data: AdminUser[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface InviteUserDto { + email: string; + name?: string; + workspaceId?: string; + role?: WorkspaceMemberRole; +} + +export interface InvitationResponse { + userId: string; + invitationToken: string; + email: string; + invitedAt: string; +} + +export interface UpdateUserDto { + name?: string; + deactivatedAt?: string | null; + emailVerified?: boolean; + preferences?: Record; +} + +/** + * Fetch paginated admin users + */ +export async function fetchAdminUsers(page?: number, limit?: number): Promise { + const params = new URLSearchParams(); + + if (page !== undefined) { + params.append("page", String(page)); + } + + if (limit !== undefined) { + params.append("limit", String(limit)); + } + + const endpoint = `/api/admin/users${params.toString() ? `?${params.toString()}` : ""}`; + return apiGet(endpoint); +} + +/** + * Invite a user by email + */ +export async function inviteUser(dto: InviteUserDto): Promise { + return apiPost("/api/admin/users/invite", dto); +} + +/** + * Update admin user fields + */ +export async function updateUser(id: string, dto: UpdateUserDto): Promise { + return apiPatch(`/api/admin/users/${id}`, dto); +} + +/** + * Deactivate a user account + */ +export async function deactivateUser(id: string): Promise { + return apiDelete(`/api/admin/users/${id}`); +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index b062b6e..e9c3640 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -16,3 +16,4 @@ export * from "./telemetry"; export * from "./dashboard"; export * from "./projects"; export * from "./workspaces"; +export * from "./admin";