Compare commits
1 Commits
fix/csrf-b
...
feat/ms21-
| Author | SHA1 | Date | |
|---|---|---|---|
| 1310eff96c |
@@ -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> => {
|
||||
|
||||
333
apps/web/src/components/admin/InviteUserDialog.tsx
Normal file
333
apps/web/src/components/admin/InviteUserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
97
apps/web/src/lib/api/admin.ts
Normal file
97
apps/web/src/lib/api/admin.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user