- InviteUserDialog.tsx - admin.ts API client Hit rate limit mid-flight. Continuing work.
334 lines
9.3 KiB
TypeScript
334 lines
9.3 KiB
TypeScript
"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="[email protected]"
|
|
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>
|
|
);
|
|
}
|