diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx index 79dcca8..e6395c3 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/[id]/page.tsx @@ -109,7 +109,6 @@ export default function WorkspaceDetailPage({ // TODO: Replace with actual role check when API is implemented // Currently hardcoded to OWNER in mock data (line 89) const canInvite = - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN; const handleUpdateWorkspace = async (name: string): Promise => { diff --git a/apps/web/src/components/admin/InviteUserDialog.tsx b/apps/web/src/components/admin/InviteUserDialog.tsx new file mode 100644 index 0000000..3841029 --- /dev/null +++ b/apps/web/src/components/admin/InviteUserDialog.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState, type ReactElement, type SyntheticEvent } from "react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { InviteUserDto, WorkspaceMemberRole } from "@/lib/api/admin"; + +/* --------------------------------------------------------------------------- + Types + --------------------------------------------------------------------------- */ + +interface WorkspaceOption { + id: string; + name: string; +} + +interface InviteUserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: InviteUserDto) => Promise; + isSubmitting: boolean; + workspaces: WorkspaceOption[]; +} + +/* --------------------------------------------------------------------------- + Validation + --------------------------------------------------------------------------- */ + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function validateEmail(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return "Email is required."; + if (!EMAIL_REGEX.test(trimmed)) return "Please enter a valid email address."; + return null; +} + +/* --------------------------------------------------------------------------- + Component + --------------------------------------------------------------------------- */ + +export function InviteUserDialog({ + open, + onOpenChange, + onSubmit, + isSubmitting, + workspaces, +}: InviteUserDialogProps): ReactElement | null { + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [workspaceId, setWorkspaceId] = useState(""); + const [role, setRole] = useState("MEMBER"); + const [formError, setFormError] = useState(null); + + function resetForm(): void { + setEmail(""); + setName(""); + setWorkspaceId(""); + setRole("MEMBER"); + setFormError(null); + } + + async function handleSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setFormError(null); + + const emailError = validateEmail(email); + if (emailError) { + setFormError(emailError); + return; + } + + try { + const dto: InviteUserDto = { + email: email.trim(), + role, + }; + const trimmedName = name.trim(); + if (trimmedName) dto.name = trimmedName; + if (workspaceId) dto.workspaceId = workspaceId; + + await onSubmit(dto); + resetForm(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to send invitation."); + } + } + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
{ + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }} + /> + + {/* Dialog */} +
+

+ Invite User +

+

+ Send an invitation to join the platform. +

+ +
{ + void handleSubmit(e); + }} + > + {/* Email */} +
+ + ) => { + setEmail(e.target.value); + }} + placeholder="user@example.com" + maxLength={254} + autoFocus + /> +
+ + {/* Name */} +
+ + ) => { + setName(e.target.value); + }} + placeholder="Full name (optional)" + maxLength={255} + /> +
+ + {/* Workspace */} + {workspaces.length > 0 && ( +
+ + +
+ )} + + {/* Role */} +
+ + +
+ + {/* Form error */} + {formError !== null && ( +

+ {formError} +

+ )} + + {/* Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/kanban/TaskCard.tsx b/apps/web/src/components/kanban/TaskCard.tsx index 683cbbf..4487194 100644 --- a/apps/web/src/components/kanban/TaskCard.tsx +++ b/apps/web/src/components/kanban/TaskCard.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ "use client"; import React from "react"; diff --git a/apps/web/src/components/knowledge/EntryCard.tsx b/apps/web/src/components/knowledge/EntryCard.tsx index 884a6d8..bab837b 100644 --- a/apps/web/src/components/knowledge/EntryCard.tsx +++ b/apps/web/src/components/knowledge/EntryCard.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ import React from "react"; import type { KnowledgeEntryWithTags } from "@mosaic/shared"; import { EntryStatus } from "@mosaic/shared"; diff --git a/apps/web/src/components/personalities/PersonalityPreview.tsx b/apps/web/src/components/personalities/PersonalityPreview.tsx index 8d32dfe..0867700 100644 --- a/apps/web/src/components/personalities/PersonalityPreview.tsx +++ b/apps/web/src/components/personalities/PersonalityPreview.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ "use client"; /* eslint-disable @typescript-eslint/no-non-null-assertion */ diff --git a/apps/web/src/components/tasks/TaskItem.test.tsx b/apps/web/src/components/tasks/TaskItem.test.tsx index 9e05005..88d056c 100644 --- a/apps/web/src/components/tasks/TaskItem.test.tsx +++ b/apps/web/src/components/tasks/TaskItem.test.tsx @@ -137,8 +137,7 @@ describe("TaskItem", (): void => { dueDate: null, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - render(); + render(); expect(screen.getByText("Test task")).toBeInTheDocument(); }); diff --git a/apps/web/src/components/tasks/TaskItem.tsx b/apps/web/src/components/tasks/TaskItem.tsx index 24bb5a4..cdbcb94 100644 --- a/apps/web/src/components/tasks/TaskItem.tsx +++ b/apps/web/src/components/tasks/TaskItem.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ import React from "react"; import type { Task } from "@mosaic/shared"; import { TaskStatus, TaskPriority } from "@mosaic/shared"; diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts new file mode 100644 index 0000000..85fbec4 --- /dev/null +++ b/apps/web/src/lib/api/admin.ts @@ -0,0 +1,97 @@ +/** + * Admin API Client + * Handles admin-scoped user and workspace management requests. + * These endpoints require AdminGuard (OWNER/ADMIN role). + */ + +import { apiGet, apiPost, apiPatch, apiDelete } from "./client"; + +/* --------------------------------------------------------------------------- + Response Types (mirrors apps/api/src/admin/types/admin.types.ts) + --------------------------------------------------------------------------- */ + +export type WorkspaceMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; + +export interface WorkspaceMembershipResponse { + workspaceId: string; + workspaceName: string; + role: WorkspaceMemberRole; + joinedAt: string; +} + +export interface AdminUserResponse { + 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: WorkspaceMembershipResponse[]; +} + +export interface PaginatedAdminResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface InvitationResponse { + userId: string; + invitationToken: string; + email: string; + invitedAt: string; +} + +/* --------------------------------------------------------------------------- + Request DTOs + --------------------------------------------------------------------------- */ + +export interface InviteUserDto { + email: string; + name?: string; + workspaceId?: string; + role?: WorkspaceMemberRole; +} + +export interface UpdateUserDto { + name?: string; + deactivatedAt?: string | null; + emailVerified?: boolean; + preferences?: Record; +} + +/* --------------------------------------------------------------------------- + API Functions + --------------------------------------------------------------------------- */ + +export async function fetchAdminUsers( + page = 1, + limit = 50 +): Promise> { + return apiGet>( + `/api/admin/users?page=${String(page)}&limit=${String(limit)}` + ); +} + +export async function inviteUser(dto: InviteUserDto): Promise { + return apiPost("/api/admin/users/invite", dto); +} + +export async function updateUser( + userId: string, + dto: UpdateUserDto +): Promise { + return apiPatch(`/api/admin/users/${userId}`, dto); +} + +export async function deactivateUser(userId: string): Promise { + await apiDelete(`/api/admin/users/${userId}`); +} diff --git a/apps/web/src/lib/hooks/useLayout.ts b/apps/web/src/lib/hooks/useLayout.ts index da5de87..5daab7b 100644 --- a/apps/web/src/lib/hooks/useLayout.ts +++ b/apps/web/src/lib/hooks/useLayout.ts @@ -109,7 +109,7 @@ export function useLayout(): UseLayoutReturn { if (stored) { const emptyFallback: Record = {}; const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback); - const parsedLayouts = parsed as Record; + const parsedLayouts = parsed; if (Object.keys(parsedLayouts).length > 0) { setLayouts(parsedLayouts); } else {