Skip to content
File

Blob: src/client/components/workspace/settings.tsx

typescript619 lines
1import { useCallback, useRef, useState } from "react";
2import { Link, useNavigate, useParams } from "@tanstack/react-router";
3import { confirm } from "@/client/components/confirm-store";
4import {
5 ArrowLeft,
6 Save,
7 Loader2,
8 Trash2,
9 ChevronDown,
10 Settings,
11 Shield,
12 Crown,
13 User as UserIcon,
14 UserPlus,
15 LogOut,
16 Copy,
17 Check,
18 X,
19} from "lucide-react";
20import { Avatar } from "@/client/components/ui/avatar";
21import { Button } from "@/client/components/ui/button";
22import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
23import { useCurrentWorkspace, useWorkspaceMembers, useWorkspaceRole } from "./use-workspace-view";
24import { directoryCommands } from "@/client/stores/db/workspace-directory";
25import { replicaCommands } from "@/client/stores/db/workspace-replica";
26import { workspaceLifecycleCommands } from "@/client/stores/db/workspace-lifecycle";
27import { useDocumentTitle } from "@/client/hooks/use-document-title";
28import { useAuthStore } from "@/client/stores/auth-store";
29import { api, toApiError } from "@/client/lib/api";
30import { toast } from "@/client/components/toast-store";
31import { useCopyFeedback } from "@/shared/browser/use-copy-feedback";
32import { EmojiPicker } from "@/client/components/ui/emoji-picker";
33import { EmojiIcon } from "@/client/components/ui/emoji-icon";
34import {
35 canChangeMemberRole,
36 canLeaveWorkspace,
37 canRemoveMember,
38 isWorkspaceAdminOrOwnerRole,
39 type ResolvedWorkspaceRole,
40} from "@/shared/entitlements";
41import type { InviteRole, Workspace, WorkspaceMember, User, WorkspaceRole } from "@/shared/types";
42 
43const ROLE_BADGE: Record<string, { label: string; className: string }> = {
44 owner: { label: "Owner", className: "bg-amber-500/10 text-amber-400 border border-amber-500/20" },
45 admin: { label: "Admin", className: "bg-blue-500/10 text-blue-400 border border-blue-500/20" },
46 member: { label: "Member", className: "bg-zinc-500/10 text-zinc-400 border border-zinc-700" },
47 guest: { label: "Guest", className: "bg-zinc-500/10 text-zinc-500 border border-zinc-700" },
48};
49 
50const ROLE_ICON: Record<string, typeof Crown> = {
51 owner: Crown,
52 admin: Shield,
53 member: UserIcon,
54 guest: UserIcon,
55};
56 
57const ASSIGNABLE_ROLES = ["admin", "member", "guest"] as const satisfies readonly InviteRole[];
58 
59export function WorkspaceSettings() {
60 const params = useParams({ strict: false }) as { workspaceSlug?: string };
61 const currentWorkspace = useCurrentWorkspace();
62 const members = useWorkspaceMembers();
63 const role = useWorkspaceRole();
64 const currentUser = useAuthStore((s) => s.user);
65 const isOwner = role === "owner";
66 const isAdminOrOwner = isWorkspaceAdminOrOwnerRole(role ?? "none");
67 const myRole: ResolvedWorkspaceRole = role ?? "none";
68 useDocumentTitle(currentWorkspace ? `Settings โ€” ${currentWorkspace.name}` : "Settings");
69 
70 if (!currentWorkspace) return null;
71 
72 return (
73 <div className="mx-auto max-w-2xl px-8 py-10">
74 <Link
75 to="/$workspaceSlug"
76 params={{ workspaceSlug: params.workspaceSlug ?? "" }}
77 className="mb-6 flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-zinc-200"
78 >
79 <ArrowLeft className="h-4 w-4" />
80 Back to {currentWorkspace.name}
81 </Link>
82
83 <div className="mb-8 flex items-center gap-3">
84 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-800/50">
85 <Settings className="h-5 w-5 text-zinc-400" />
86 </div>
87 <h1 className="text-xl font-semibold text-zinc-100">Workspace Settings</h1>
88 </div>
89
90 {isOwner && <ProfileSection key={currentWorkspace.id} workspace={currentWorkspace} />}
91
92 <MembersSection workspace={currentWorkspace} members={members} currentUser={currentUser} myRole={myRole} />
93
94 {myRole !== "guest" && myRole !== "none" && (
95 <InviteSection workspace={currentWorkspace} isAdminOrOwner={isAdminOrOwner} />
96 )}
97
98 {canLeaveWorkspace(myRole) && <LeaveWorkspaceSection workspace={currentWorkspace} />}
99
100 {isOwner && <DangerSection workspace={currentWorkspace} />}
101 </div>
102 );
103}
104 
105function ProfileSection({ workspace }: { workspace: Workspace }) {
106 const [name, setName] = useState(workspace.name);
107 const [icon, setIcon] = useState(workspace.icon ?? "");
108 const [saving, setSaving] = useState(false);
109 const [saveError, setSaveError] = useState<string | null>(null);
110 const [iconPickerOpen, setIconPickerOpen] = useState(false);
111 const iconTriggerRef = useRef<HTMLButtonElement>(null);
112 
113 const hasChanges = name.trim() !== workspace.name || (icon.trim() || null) !== (workspace.icon ?? null);
114 
115 const handleSave = useCallback(async () => {
116 if (saving) return;
117 setSaving(true);
118 setSaveError(null);
119 try {
120 const updated = await api.workspaces.update(workspace.id, {
121 name: name.trim(),
122 icon: icon.trim() || null,
123 });
124 // `updated` is a plain Workspace (no role). Both stores take the plain
125 // patch and preserve their role-aware fields.
126 await replicaCommands.patchWorkspaceHead(workspace.id, updated);
127 await directoryCommands.patch(workspace.id, updated);
128 setName(updated.name);
129 setIcon(updated.icon ?? "");
130 } catch (err) {
131 setSaveError(toApiError(err).message);
132 } finally {
133 setSaving(false);
134 }
135 }, [workspace.id, name, icon, saving]);
136 
137 return (
138 <section className="mb-8">
139 <h2 className="mb-4 text-lg font-semibold text-zinc-200">Workspace Info</h2>
140 <div className="space-y-4 rounded-lg border border-zinc-800 p-4">
141 <div>
142 <label htmlFor="ws-name" className="mb-1 block text-sm font-medium text-zinc-400">
143 Name
144 </label>
145 <div className="flex items-center gap-2">
146 <div className="group/wsicon flex shrink-0 items-center gap-0.5">
147 <button
148 ref={iconTriggerRef}
149 onClick={() => setIconPickerOpen((o) => !o)}
150 className="flex h-[38px] w-10 items-center justify-center rounded-lg border border-zinc-700 bg-zinc-800/50 text-lg transition-colors hover:border-zinc-600"
151 aria-label={icon ? "Change icon" : "Add icon"}
152 >
153 {icon ? <EmojiIcon emoji={icon} size={20} /> : <span className="text-xs text-zinc-500">๐Ÿ˜€</span>}
154 </button>
155 {icon && (
156 <button
157 onClick={() => setIcon("")}
158 className="flex h-6 w-6 items-center justify-center rounded-md text-zinc-500 opacity-0 transition hover:bg-zinc-700 hover:text-zinc-100 group-hover/wsicon:opacity-100"
159 aria-label="Remove icon"
160 >
161 <X className="h-3.5 w-3.5" />
162 </button>
163 )}
164 </div>
165 <input
166 id="ws-name"
167 type="text"
168 value={name}
169 onChange={(e) => setName(e.target.value)}
170 className="min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
171 placeholder="Workspace name"
172 />
173 {iconPickerOpen && (
174 <DropdownPortal
175 triggerRef={iconTriggerRef}
176 align="left"
177 width={360}
178 onClose={() => setIconPickerOpen(false)}
179 className="border-0 bg-transparent shadow-none"
180 >
181 <EmojiPicker
182 onSelect={(emoji) => {
183 setIcon(emoji);
184 setIconPickerOpen(false);
185 }}
186 />
187 </DropdownPortal>
188 )}
189 </div>
190 </div>
191 {saveError && <p className="text-sm text-red-400">{saveError}</p>}
192 <div className="flex justify-end">
193 <Button
194 variant="primary"
195 size="sm"
196 onClick={handleSave}
197 disabled={saving || !name.trim() || !hasChanges}
198 icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
199 >
200 {saving ? "Saving..." : "Save changes"}
201 </Button>
202 </div>
203 </div>
204 </section>
205 );
206}
207 
208interface MembersSectionProps {
209 workspace: Workspace;
210 members: WorkspaceMember[];
211 currentUser: User | null;
212 myRole: ResolvedWorkspaceRole;
213}
214 
215function MembersSection({ workspace, members, currentUser, myRole }: MembersSectionProps) {
216 const [updatingRole, setUpdatingRole] = useState<string | null>(null);
217 const [removingMember, setRemovingMember] = useState<string | null>(null);
218 const [memberError, setMemberError] = useState<string | null>(null);
219 
220 const handleRoleChange = useCallback(
221 async (userId: string, newRole: string) => {
222 setUpdatingRole(userId);
223 setMemberError(null);
224 try {
225 await api.workspaces.updateMemberRole(workspace.id, userId, newRole);
226 await replicaCommands.replaceMembers(
227 workspace.id,
228 members.map((m) => (m.user_id === userId ? { ...m, role: newRole as WorkspaceRole } : m)),
229 );
230 } catch (err) {
231 setMemberError(toApiError(err).message);
232 } finally {
233 setUpdatingRole(null);
234 }
235 },
236 [workspace.id, members],
237 );
238 
239 const handleRemoveMember = useCallback(
240 async (userId: string, memberName: string) => {
241 const ok = await confirm({
242 title: "Remove member",
243 message: `${memberName} will lose access to this workspace.`,
244 confirmLabel: "Remove",
245 variant: "danger",
246 });
247 if (!ok) return;
248 setRemovingMember(userId);
249 setMemberError(null);
250 try {
251 await api.workspaces.removeMember(workspace.id, userId);
252 await replicaCommands.replaceMembers(
253 workspace.id,
254 members.filter((m) => m.user_id !== userId),
255 );
256 } catch (err) {
257 setMemberError(toApiError(err).message);
258 } finally {
259 setRemovingMember(null);
260 }
261 },
262 [workspace.id, members],
263 );
264 
265 return (
266 <section className="mb-8">
267 <h2 className="mb-4 text-lg font-semibold text-zinc-200">
268 Members <span className="text-sm font-normal text-zinc-400">({members.length})</span>
269 </h2>
270 {memberError && <p className="mb-3 text-sm text-red-400">{memberError}</p>}
271 <div className="space-y-2">
272 {members.map((member, memberIndex) => (
273 <MemberRow
274 key={member.user_id}
275 member={member}
276 memberIndex={memberIndex}
277 currentUser={currentUser}
278 myRole={myRole}
279 updatingRole={updatingRole}
280 removingMember={removingMember}
281 onRoleChange={handleRoleChange}
282 onRemove={handleRemoveMember}
283 />
284 ))}
285 </div>
286 </section>
287 );
288}
289 
290interface MemberRowProps {
291 member: WorkspaceMember;
292 memberIndex: number;
293 currentUser: User | null;
294 myRole: ResolvedWorkspaceRole;
295 updatingRole: string | null;
296 removingMember: string | null;
297 onRoleChange: (userId: string, newRole: string) => void;
298 onRemove: (userId: string, memberName: string) => void;
299}
300 
301function MemberRow({
302 member,
303 memberIndex,
304 currentUser,
305 myRole,
306 updatingRole,
307 removingMember,
308 onRoleChange,
309 onRemove,
310}: MemberRowProps) {
311 const [roleDropdownOpen, setRoleDropdownOpen] = useState(false);
312 const triggerRef = useRef<HTMLButtonElement>(null);
313 
314 const user = member.user;
315 const displayName = user?.name ?? "Unknown";
316 const displayEmail = user?.email ?? "";
317 const badge = ROLE_BADGE[member.role] ?? ROLE_BADGE.member;
318 const RoleIcon = ROLE_ICON[member.role] ?? UserIcon;
319 const isSelf = member.user_id === currentUser?.id;
320 const isMemberOwner = member.role === "owner";
321 // Row-level affordance mirrors worker policy. `allowedRoles` filters
322 // the dropdown so admins don't see options the worker will reject
323 // (e.g. promote-to-admin). `canRemove` hides the delete action for
324 // admin-vs-admin and owner-target cases.
325 const allowedRoles = ASSIGNABLE_ROLES.filter(
326 (option) => option !== member.role && canChangeMemberRole(myRole, member.role, option),
327 );
328 const canChangeRole = !isSelf && allowedRoles.length > 0;
329 const canRemove = !isSelf && !isMemberOwner && canRemoveMember(myRole, member.role, false);
330 
331 return (
332 <div
333 className="flex items-center justify-between rounded-lg border border-zinc-800 px-4 py-3 opacity-0 animate-slide-up"
334 style={{ animationDelay: `${Math.min(memberIndex, 7) * 60}ms` }}
335 >
336 <div className="flex items-center gap-3 overflow-hidden">
337 <Avatar name={displayName} avatarUrl={user?.avatar_url} className="h-8 w-8 border border-zinc-700 text-sm" />
338 <div className="min-w-0">
339 <p className="truncate text-sm font-medium text-zinc-200">
340 {displayName}
341 {isSelf && <span className="ml-1 text-xs text-zinc-500">(you)</span>}
342 </p>
343 {displayEmail && <p className="truncate text-xs text-zinc-400">{displayEmail}</p>}
344 </div>
345 </div>
346
347 <div className="flex shrink-0 items-center gap-2">
348 {canChangeRole ? (
349 <>
350 <button
351 ref={triggerRef}
352 onClick={() => setRoleDropdownOpen((o) => !o)}
353 disabled={updatingRole === member.user_id}
354 className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${badge.className} transition-opacity hover:opacity-80`}
355 >
356 {updatingRole === member.user_id ? (
357 <Loader2 className="h-3 w-3 animate-spin" />
358 ) : (
359 <>
360 {badge.label}
361 <ChevronDown className="h-3 w-3" />
362 </>
363 )}
364 </button>
365 {roleDropdownOpen && (
366 <DropdownPortal
367 triggerRef={triggerRef}
368 align="right"
369 width={128}
370 onClose={() => setRoleDropdownOpen(false)}
371 className="py-1"
372 >
373 {allowedRoles.map((role) => (
374 <button
375 key={role}
376 onClick={() => {
377 setRoleDropdownOpen(false);
378 onRoleChange(member.user_id, role);
379 }}
380 className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-zinc-400 transition-colors hover:bg-zinc-700/50 hover:text-zinc-200"
381 >
382 {role.charAt(0).toUpperCase() + role.slice(1)}
383 </button>
384 ))}
385 </DropdownPortal>
386 )}
387 </>
388 ) : (
389 <span
390 className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${badge.className}`}
391 >
392 <RoleIcon className="h-3 w-3" />
393 {badge.label}
394 </span>
395 )}
396 
397 {canRemove && (
398 <Button
399 variant="ghost"
400 size="sm"
401 iconOnly
402 onClick={() => onRemove(member.user_id, displayName)}
403 disabled={removingMember === member.user_id}
404 aria-label={`Remove ${displayName}`}
405 icon={
406 removingMember === member.user_id ? (
407 <Loader2 className="h-3.5 w-3.5 animate-spin" />
408 ) : (
409 <Trash2 className="h-3.5 w-3.5" />
410 )
411 }
412 className="hover:text-red-400"
413 />
414 )}
415 </div>
416 </div>
417 );
418}
419 
420function InviteSection({ workspace, isAdminOrOwner }: { workspace: Workspace; isAdminOrOwner: boolean }) {
421 const [inviteRole, setInviteRole] = useState<"admin" | "member" | "guest">("member");
422 const [inviteEmail, setInviteEmail] = useState("");
423 const [inviteLoading, setInviteLoading] = useState(false);
424 const [inviteLink, setInviteLink] = useState<string | null>(null);
425 const { copiedId: inviteCopied, copy: copyInviteToClipboard } = useCopyFeedback<string>();
426 const [inviteError, setInviteError] = useState<string | null>(null);
427 
428 const handleCreateInvite = useCallback(async () => {
429 if (inviteLoading) return;
430 setInviteLoading(true);
431 setInviteError(null);
432 setInviteLink(null);
433 try {
434 const invite = await api.invites.create(workspace.id, {
435 role: inviteRole,
436 email: inviteEmail.trim() || undefined,
437 });
438 setInviteLink(`${window.location.origin}/invite/${invite.token}`);
439 setInviteEmail("");
440 } catch (err) {
441 setInviteError(toApiError(err).message);
442 } finally {
443 setInviteLoading(false);
444 }
445 }, [workspace.id, inviteRole, inviteEmail, inviteLoading]);
446 
447 const copyInviteLink = useCallback(() => {
448 if (!inviteLink) return;
449 copyInviteToClipboard("invite", inviteLink);
450 }, [inviteLink, copyInviteToClipboard]);
451 
452 return (
453 <section className="mb-8">
454 <h2 className="mb-4 text-lg font-semibold text-zinc-200">
455 <UserPlus className="mr-2 inline h-5 w-5" />
456 Invite
457 </h2>
458 <div className="space-y-3 rounded-lg border border-zinc-800 p-4">
459 <div className="flex items-center gap-2">
460 <input
461 type="email"
462 value={inviteEmail}
463 onChange={(e) => setInviteEmail(e.target.value)}
464 placeholder="Email (optional)"
465 aria-label="Invite email address"
466 className="flex-1 rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
467 />
468 <div className="relative">
469 <select
470 value={inviteRole}
471 onChange={(e) => setInviteRole(e.target.value as "admin" | "member" | "guest")}
472 className="appearance-none rounded-lg border border-zinc-700 bg-zinc-800/50 py-2 pl-3 pr-8 text-sm text-zinc-300 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
473 >
474 {isAdminOrOwner && (
475 <option value="admin" className="bg-zinc-800 text-zinc-300">
476 Admin
477 </option>
478 )}
479 <option value="member" className="bg-zinc-800 text-zinc-300">
480 Member
481 </option>
482 <option value="guest" className="bg-zinc-800 text-zinc-300">
483 Guest
484 </option>
485 </select>
486 <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-500" />
487 </div>
488 </div>
489 <div className="flex items-center gap-2">
490 <Button
491 variant="primary"
492 size="sm"
493 onClick={handleCreateInvite}
494 disabled={inviteLoading}
495 icon={inviteLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
496 >
497 Create invite
498 </Button>
499 </div>
500 {inviteError && <p className="text-sm text-red-400">{inviteError}</p>}
501 {inviteLink && (
502 <div className="flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-800/50 px-3 py-2">
503 <p className="min-w-0 flex-1 truncate text-xs text-zinc-400">{inviteLink}</p>
504 <Button
505 variant="ghost"
506 size="sm"
507 iconOnly
508 onClick={copyInviteLink}
509 aria-label="Copy invite link"
510 icon={inviteCopied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
511 className="shrink-0"
512 />
513 </div>
514 )}
515 </div>
516 </section>
517 );
518}
519 
520function LeaveWorkspaceSection({ workspace }: { workspace: Workspace }) {
521 const navigate = useNavigate();
522 const currentUser = useAuthStore((s) => s.user);
523 const [leaving, setLeaving] = useState(false);
524 
525 const handleLeave = useCallback(async () => {
526 if (leaving || !currentUser) return;
527 const ok = await confirm({
528 title: "Leave workspace",
529 message: `You'll lose access to "${workspace.name}" and everything shared with you inside it. An admin can re-invite you if you change your mind.`,
530 confirmLabel: "Leave",
531 variant: "danger",
532 });
533 if (!ok) return;
534 setLeaving(true);
535 try {
536 await api.workspaces.removeMember(workspace.id, currentUser.id);
537 // Cascade across directory, replica, navigation, pageAccess. Prevents
538 // root routing from redirecting the user back into a workspace they
539 // just left.
540 await workspaceLifecycleCommands.removeWorkspace(workspace.id);
541 navigate({ to: "/" });
542 } catch (err) {
543 toast.error(toApiError(err).message);
544 setLeaving(false);
545 }
546 }, [workspace.id, workspace.name, currentUser, leaving, navigate]);
547 
548 return (
549 <section className="mb-8">
550 <h2 className="mb-4 text-lg font-semibold text-zinc-200">Leave workspace</h2>
551 <div className="rounded-lg border border-zinc-800 p-4">
552 <div className="flex items-center justify-between">
553 <div>
554 <p className="text-sm font-medium text-zinc-200">Leave "{workspace.name}"</p>
555 <p className="text-xs text-zinc-400">You can rejoin later if re-invited.</p>
556 </div>
557 <Button
558 variant="secondary"
559 size="sm"
560 onClick={handleLeave}
561 disabled={leaving || !currentUser}
562 icon={leaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="h-4 w-4" />}
563 >
564 Leave workspace
565 </Button>
566 </div>
567 </div>
568 </section>
569 );
570}
571 
572function DangerSection({ workspace }: { workspace: Workspace }) {
573 const navigate = useNavigate();
574 const [deleting, setDeleting] = useState(false);
575 
576 const handleDeleteWorkspace = useCallback(async () => {
577 if (deleting) return;
578 const ok = await confirm({
579 title: "Delete workspace",
580 message: `"${workspace.name}" and all its pages will be permanently deleted. This cannot be undone.`,
581 confirmLabel: "Delete",
582 variant: "danger",
583 });
584 if (!ok) return;
585 setDeleting(true);
586 try {
587 await api.workspaces.delete(workspace.id);
588 await workspaceLifecycleCommands.removeWorkspace(workspace.id);
589 navigate({ to: "/" });
590 } catch {
591 toast.error("Failed to delete workspace");
592 setDeleting(false);
593 }
594 }, [workspace.id, workspace.name, deleting, navigate]);
595 
596 return (
597 <section>
598 <h2 className="mb-4 text-lg font-semibold text-red-400">Danger Zone</h2>
599 <div className="rounded-lg border border-red-500/20 p-4">
600 <div className="flex items-center justify-between">
601 <div>
602 <p className="text-sm font-medium text-zinc-200">Delete this workspace</p>
603 <p className="text-xs text-zinc-400">All pages and data will be permanently removed.</p>
604 </div>
605 <Button
606 variant="danger"
607 size="sm"
608 onClick={handleDeleteWorkspace}
609 disabled={deleting}
610 icon={deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
611 >
612 Delete workspace
613 </Button>
614 </div>
615 </div>
616 </section>
617 );
618}