feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) (#592)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #592.
This commit is contained in:
2026-03-01 03:54:32 +00:00
committed by jason.woltje
parent 1df20f0e13
commit 7106512fa9
5 changed files with 1046 additions and 308 deletions

View File

@@ -5,12 +5,14 @@ import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import Link from "next/link";
import { Pencil, UserPlus, UserX } from "lucide-react";
import { UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { isValidEmail } from "@/components/workspace/validation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,7 +44,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import {
deactivateUser,
fetchAdminUsers,
@@ -50,9 +51,11 @@ import {
updateUser,
type AdminUser,
type AdminUsersResponse,
type AdminWorkspaceMembership,
type InviteUserDto,
type UpdateUserDto,
} from "@/lib/api/admin";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.OWNER]: 4,
@@ -63,27 +66,40 @@ const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
const INITIAL_INVITE_FORM = {
email: "",
name: "",
workspaceId: "",
role: WorkspaceMemberRole.MEMBER,
};
const INITIAL_DETAIL_FORM = {
name: "",
email: "",
role: WorkspaceMemberRole.MEMBER,
workspaceId: null as string | null,
workspaceName: null as string | null,
};
interface DetailInitialState {
name: string;
email: string;
role: WorkspaceMemberRole;
workspaceId: string | null;
}
function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
}
function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null {
function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | 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;
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest.role]) {
return membership;
}
return highest;
}, firstMembership.role);
}, firstMembership);
}
export default function UsersSettingsPage(): ReactElement {
@@ -93,21 +109,23 @@ export default function UsersSettingsPage(): ReactElement {
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false);
const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM);
const [inviteError, setInviteError] = useState<string | null>(null);
const [isInviting, setIsInviting] = useState<boolean>(false);
const [detailTarget, setDetailTarget] = useState<AdminUser | null>(null);
const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
const [editName, setEditName] = useState<string>("");
const [editError, setEditError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
@@ -139,9 +157,12 @@ export default function UsersSettingsPage(): ReactElement {
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
setDefaultWorkspaceId(workspaces[0]?.id ?? null);
setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
})
.catch(() => {
setDefaultWorkspaceId(null);
setIsAdmin(true); // fail open
});
}, []);
@@ -151,15 +172,44 @@ export default function UsersSettingsPage(): ReactElement {
setInviteError(null);
}
function handleInviteOpenChange(open: boolean): void {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
function openUserDetails(user: AdminUser): void {
const primaryMembership = getPrimaryMembership(user);
const nextDetailForm = {
name: user.name,
email: user.email,
role: primaryMembership?.role ?? WorkspaceMemberRole.MEMBER,
workspaceId: primaryMembership?.workspaceId ?? null,
workspaceName: primaryMembership?.workspaceName ?? null,
};
setDetailTarget(user);
setDetailForm(nextDetailForm);
setDetailInitial({
name: nextDetailForm.name,
email: nextDetailForm.email,
role: nextDetailForm.role,
workspaceId: nextDetailForm.workspaceId,
});
setDetailError(null);
}
async function handleInviteSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
function resetUserDetails(): void {
setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleUserRowKeyDown(event: KeyboardEvent<HTMLDivElement>, user: AdminUser): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openUserDetails(user);
}
}
async function handleInviteSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setInviteError(null);
const email = inviteForm.email.trim();
@@ -168,17 +218,18 @@ export default function UsersSettingsPage(): ReactElement {
return;
}
const dto: InviteUserDto = { email };
const name = inviteForm.name.trim();
if (name) {
dto.name = name;
if (!isValidEmail(email)) {
setInviteError("Please enter a valid email address.");
return;
}
const workspaceId = inviteForm.workspaceId.trim();
if (workspaceId) {
dto.workspaceId = workspaceId;
dto.role = inviteForm.role;
const dto: InviteUserDto = {
email,
role: inviteForm.role,
};
if (defaultWorkspaceId) {
dto.workspaceId = defaultWorkspaceId;
}
try {
@@ -194,6 +245,75 @@ export default function UsersSettingsPage(): ReactElement {
}
}
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
if (detailTarget === null || detailInitial === null) {
return;
}
const name = detailForm.name.trim();
const email = detailForm.email.trim();
if (!name) {
setDetailError("Name is required.");
return;
}
if (!email) {
setDetailError("Email is required.");
return;
}
if (!isValidEmail(email)) {
setDetailError("Please enter a valid email address.");
return;
}
const didUpdateUser = name !== detailInitial.name || email !== detailInitial.email;
const didUpdateRole =
detailForm.workspaceId !== null &&
detailForm.workspaceId === detailInitial.workspaceId &&
detailForm.role !== detailInitial.role;
if (!didUpdateUser && !didUpdateRole) {
resetUserDetails();
return;
}
try {
setIsSavingDetails(true);
setDetailError(null);
if (didUpdateUser) {
const dto: UpdateUserDto = {};
if (name !== detailInitial.name) {
dto.name = name;
}
if (email !== detailInitial.email) {
dto.email = email;
}
await updateUser(detailTarget.id, dto);
}
if (didUpdateRole && detailForm.workspaceId !== null) {
await updateWorkspaceMemberRole(detailForm.workspaceId, detailTarget.id, {
role: detailForm.role,
});
}
resetUserDetails();
await loadUsers(false);
} catch (err: unknown) {
setDetailError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsSavingDetails(false);
}
}
async function confirmDeactivate(): Promise<void> {
if (!deactivateTarget) {
return;
@@ -212,23 +332,6 @@ export default function UsersSettingsPage(): ReactElement {
}
}
async function handleEditSubmit(): Promise<void> {
if (editTarget === null) return;
setIsEditing(true);
setEditError(null);
try {
const dto: UpdateUserDto = {};
if (editName.trim()) dto.name = editName.trim();
await updateUser(editTarget.id, dto);
setEditTarget(null);
await loadUsers(false);
} catch (err: unknown) {
setEditError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsEditing(false);
}
}
if (isAdmin === false) {
return (
<div className="p-8 max-w-2xl">
@@ -262,7 +365,15 @@ export default function UsersSettingsPage(): ReactElement {
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Dialog open={isInviteOpen} onOpenChange={handleInviteOpenChange}>
<Dialog
open={isInviteOpen}
onOpenChange={(open) => {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
}}
>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
@@ -273,13 +384,13 @@ export default function UsersSettingsPage(): ReactElement {
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Create an invited account and optionally assign workspace access.
Invite a new user and assign their role for your default workspace.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
void handleInviteSubmit(e);
onSubmit={(event) => {
void handleInviteSubmit(event);
}}
className="space-y-4"
>
@@ -289,8 +400,8 @@ export default function UsersSettingsPage(): ReactElement {
id="invite-email"
type="email"
value={inviteForm.email}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: e.target.value }));
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
@@ -298,33 +409,6 @@ export default function UsersSettingsPage(): ReactElement {
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Input
id="invite-name"
type="text"
value={inviteForm.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, name: e.target.value }));
}}
placeholder="Jane Doe"
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-workspace-id">Workspace ID (optional)</Label>
<Input
id="invite-workspace-id"
type="text"
value={inviteForm.workspaceId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value }));
}}
placeholder="UUID workspace id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Role</Label>
<Select
@@ -344,9 +428,13 @@ export default function UsersSettingsPage(): ReactElement {
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Role is only applied when workspace ID is provided.
</p>
{defaultWorkspaceId ? (
<p className="text-xs text-muted-foreground">Role will be applied on invite.</p>
) : (
<p className="text-xs text-muted-foreground">
No default workspace found. User will be invited without workspace assignment.
</p>
)}
</div>
{inviteError ? (
@@ -360,7 +448,10 @@ export default function UsersSettingsPage(): ReactElement {
type="button"
variant="outline"
onClick={() => {
handleInviteOpenChange(false);
if (!isInviting) {
setIsInviteOpen(false);
resetInviteForm();
}
}}
disabled={isInviting}
>
@@ -409,17 +500,25 @@ export default function UsersSettingsPage(): ReactElement {
<Card>
<CardHeader>
<CardTitle>User Directory</CardTitle>
<CardDescription>Name, email, role, and account status.</CardDescription>
<CardDescription>Click a user to view details or edit profile fields.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{users.map((user) => {
const primaryRole = getPrimaryRole(user);
const primaryMembership = getPrimaryMembership(user);
const isActive = user.deactivatedAt === null;
return (
<div
key={user.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30"
role="button"
tabIndex={0}
onClick={() => {
openUserDetails(user);
}}
onKeyDown={(event) => {
handleUserRowKeyDown(event, user);
}}
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
@@ -428,28 +527,17 @@ export default function UsersSettingsPage(): ReactElement {
<div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline">
{primaryRole ? toRoleLabel(primaryRole) : "No role"}
{primaryMembership ? toRoleLabel(primaryMembership.role) : "No role"}
</Badge>
<Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditTarget(user);
setEditName(user.name);
setEditError(null);
}}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Role
</Button>
{isActive ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
onClick={(event) => {
event.stopPropagation();
setDeactivateTarget(user);
}}
>
@@ -465,6 +553,117 @@ export default function UsersSettingsPage(): ReactElement {
</Card>
)}
<Dialog
open={detailTarget !== null}
onOpenChange={(open) => {
if (!open && !isSavingDetails) {
resetUserDetails();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>User Details</DialogTitle>
<DialogDescription>
Edit profile details for {detailTarget?.email ?? "selected user"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleDetailSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="detail-name">Name</Label>
<Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Full name"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-email">Email</Label>
<Input
id="detail-email"
type="email"
value={detailForm.email}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-role">Role</Label>
<Select
value={detailForm.role}
disabled={detailForm.workspaceId === null || isSavingDetails}
onValueChange={(value) => {
setDetailForm((prev) => ({ ...prev, role: value as WorkspaceMemberRole }));
}}
>
<SelectTrigger id="detail-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
{detailForm.workspaceName ? (
<p className="text-xs text-muted-foreground">
Role updates apply to: {detailForm.workspaceName}
</p>
) : (
<p className="text-xs text-muted-foreground">
This user has no workspace membership. Role cannot be updated.
</p>
)}
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetUserDetails();
}
}}
disabled={isSavingDetails}
>
Cancel
</Button>
<Button type="submit" disabled={isSavingDetails}>
{isSavingDetails ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deactivateTarget !== null}
onOpenChange={(open) => {
@@ -496,55 +695,4 @@ export default function UsersSettingsPage(): ReactElement {
</AlertDialog>
</div>
);
<Dialog
open={editTarget !== null}
onOpenChange={(open) => {
if (!open && !isEditing) {
setEditTarget(null);
setEditError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User Role</DialogTitle>
<DialogDescription>Change role for {editTarget?.email ?? "user"}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{editError !== null ? <p className="text-sm text-destructive">{editError}</p> : null}
<div className="space-y-2">
<Label htmlFor="edit-name">Display Name</Label>
<Input
id="edit-name"
value={editName}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setEditName(e.target.value);
}}
placeholder="Full name"
disabled={isEditing}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditTarget(null);
}}
disabled={isEditing}
>
Cancel
</Button>
<Button
onClick={() => {
void handleEditSubmit();
}}
disabled={isEditing}
>
{isEditing ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}