File
Blob: src/client/components/workspace/settings.tsx
| 1 | import { useCallback, useRef, useState } from "react"; |
| 2 | import { Link, useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { confirm } from "@/client/components/confirm-store"; |
| 4 | import { |
| 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"; |
| 20 | import { Avatar } from "@/client/components/ui/avatar"; |
| 21 | import { Button } from "@/client/components/ui/button"; |
| 22 | import { DropdownPortal } from "@/client/components/ui/dropdown-portal"; |
| 23 | import { useCurrentWorkspace, useWorkspaceMembers, useWorkspaceRole } from "./use-workspace-view"; |
| 24 | import { directoryCommands } from "@/client/stores/db/workspace-directory"; |
| 25 | import { replicaCommands } from "@/client/stores/db/workspace-replica"; |
| 26 | import { workspaceLifecycleCommands } from "@/client/stores/db/workspace-lifecycle"; |
| 27 | import { useDocumentTitle } from "@/client/hooks/use-document-title"; |
| 28 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 29 | import { api, toApiError } from "@/client/lib/api"; |
| 30 | import { toast } from "@/client/components/toast-store"; |
| 31 | import { useCopyFeedback } from "@/shared/browser/use-copy-feedback"; |
| 32 | import { EmojiPicker } from "@/client/components/ui/emoji-picker"; |
| 33 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 34 | import { |
| 35 | canChangeMemberRole, |
| 36 | canLeaveWorkspace, |
| 37 | canRemoveMember, |
| 38 | isWorkspaceAdminOrOwnerRole, |
| 39 | type ResolvedWorkspaceRole, |
| 40 | } from "@/shared/entitlements"; |
| 41 | import type { InviteRole, Workspace, WorkspaceMember, User, WorkspaceRole } from "@/shared/types"; |
| 42 | |
| 43 | const 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 | |
| 50 | const ROLE_ICON: Record<string, typeof Crown> = { |
| 51 | owner: Crown, |
| 52 | admin: Shield, |
| 53 | member: UserIcon, |
| 54 | guest: UserIcon, |
| 55 | }; |
| 56 | |
| 57 | const ASSIGNABLE_ROLES = ["admin", "member", "guest"] as const satisfies readonly InviteRole[]; |
| 58 | |
| 59 | export 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 | |
| 105 | function 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 | |
| 208 | interface MembersSectionProps { |
| 209 | workspace: Workspace; |
| 210 | members: WorkspaceMember[]; |
| 211 | currentUser: User | null; |
| 212 | myRole: ResolvedWorkspaceRole; |
| 213 | } |
| 214 | |
| 215 | function 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 | |
| 290 | interface 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 | |
| 301 | function 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 | |
| 420 | function 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 | |
| 520 | function 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 | |
| 572 | function 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 | } |