feat(web): add admin user management components (in progress)
Some checks failed
ci/woodpecker/push/web Pipeline failed

- InviteUserDialog.tsx
- admin.ts API client

Hit rate limit mid-flight. Continuing work.
This commit is contained in:
2026-02-28 12:47:44 -06:00
parent 85d3f930f3
commit 1310eff96c
9 changed files with 432 additions and 8 deletions

View File

@@ -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<void> => {

View File

@@ -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<void>;
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<WorkspaceMemberRole>("MEMBER");
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setEmail("");
setName("");
setWorkspaceId("");
setRole("MEMBER");
setFormError(null);
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="invite-user-title"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Backdrop */}
<div
data-testid="invite-dialog-backdrop"
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}
onClick={() => {
if (!isSubmitting) {
resetForm();
onOpenChange(false);
}
}}
/>
{/* Dialog */}
<div
style={{
position: "relative",
background: "var(--surface, #fff)",
borderRadius: "8px",
border: "1px solid var(--border, #e5e7eb)",
padding: 24,
width: "100%",
maxWidth: 480,
zIndex: 1,
maxHeight: "90vh",
overflowY: "auto",
}}
>
<h2
id="invite-user-title"
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text, #111)",
margin: "0 0 8px",
}}
>
Invite User
</h2>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
Send an invitation to join the platform.
</p>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{/* Email */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="invite-email"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Email <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
}}
placeholder="user@example.com"
maxLength={254}
autoFocus
/>
</div>
{/* Name */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="invite-name"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Name
</label>
<Input
id="invite-name"
type="text"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}}
placeholder="Full name (optional)"
maxLength={255}
/>
</div>
{/* Workspace */}
{workspaces.length > 0 && (
<div style={{ marginBottom: 16 }}>
<label
htmlFor="invite-workspace"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Workspace
</label>
<Select
value={workspaceId}
onValueChange={(v) => {
setWorkspaceId(v);
}}
>
<SelectTrigger id="invite-workspace">
<SelectValue placeholder="Select a workspace (optional)" />
</SelectTrigger>
<SelectContent>
{workspaces.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Role */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="invite-role"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Role
</label>
<Select
value={role}
onValueChange={(v) => {
setRole(v as WorkspaceMemberRole);
}}
>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADMIN">Admin</SelectItem>
</SelectContent>
</Select>
</div>
{/* Form error */}
{formError !== null && (
<p
role="alert"
style={{
color: "var(--danger, #ef4444)",
fontSize: "0.85rem",
margin: "0 0 12px",
}}
>
{formError}
</p>
)}
{/* Buttons */}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 8 }}>
<button
type="button"
onClick={() => {
resetForm();
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text-2, #374151)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !email.trim()}
style={{
padding: "8px 16px",
background: "var(--primary, #111827)",
border: "none",
borderRadius: "6px",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: isSubmitting || !email.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !email.trim() ? 0.6 : 1,
}}
>
{isSubmitting ? "Sending..." : "Send Invitation"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
"use client";
import React from "react";

View File

@@ -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";

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
"use client";
/* eslint-disable @typescript-eslint/no-non-null-assertion */

View File

@@ -137,8 +137,7 @@ describe("TaskItem", (): void => {
dueDate: null,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(<TaskItem task={taskWithoutDueDate as any} />);
render(<TaskItem task={taskWithoutDueDate} />);
expect(screen.getByText("Test task")).toBeInTheDocument();
});

View File

@@ -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";

View File

@@ -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<T> {
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<string, unknown>;
}
/* ---------------------------------------------------------------------------
API Functions
--------------------------------------------------------------------------- */
export async function fetchAdminUsers(
page = 1,
limit = 50
): Promise<PaginatedAdminResponse<AdminUserResponse>> {
return apiGet<PaginatedAdminResponse<AdminUserResponse>>(
`/api/admin/users?page=${String(page)}&limit=${String(limit)}`
);
}
export async function inviteUser(dto: InviteUserDto): Promise<InvitationResponse> {
return apiPost<InvitationResponse>("/api/admin/users/invite", dto);
}
export async function updateUser(
userId: string,
dto: UpdateUserDto
): Promise<AdminUserResponse> {
return apiPatch<AdminUserResponse>(`/api/admin/users/${userId}`, dto);
}
export async function deactivateUser(userId: string): Promise<void> {
await apiDelete(`/api/admin/users/${userId}`);
}

View File

@@ -109,7 +109,7 @@ export function useLayout(): UseLayoutReturn {
if (stored) {
const emptyFallback: Record<string, LayoutConfig> = {};
const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback);
const parsedLayouts = parsed as Record<string, LayoutConfig>;
const parsedLayouts = parsed;
if (Object.keys(parsedLayouts).length > 0) {
setLayouts(parsedLayouts);
} else {