import { useCallback, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { confirm } from "@/client/components/confirm-store"; import { ArrowLeft, Save, Loader2, Trash2, ChevronDown, Settings, Shield, Crown, User as UserIcon, UserPlus, LogOut, Copy, Check, X, } from "lucide-react"; import { Avatar } from "@/client/components/ui/avatar"; import { Button } from "@/client/components/ui/button"; import { DropdownPortal } from "@/client/components/ui/dropdown-portal"; import { useCurrentWorkspace, useWorkspaceMembers, useWorkspaceRole } from "./use-workspace-view"; import { directoryCommands } from "@/client/stores/db/workspace-directory"; import { replicaCommands } from "@/client/stores/db/workspace-replica"; import { workspaceLifecycleCommands } from "@/client/stores/db/workspace-lifecycle"; import { useDocumentTitle } from "@/client/hooks/use-document-title"; import { useAuthStore } from "@/client/stores/auth-store"; import { api, toApiError } from "@/client/lib/api"; import { toast } from "@/client/components/toast-store"; import { useCopyFeedback } from "@/shared/browser/use-copy-feedback"; import { EmojiPicker } from "@/client/components/ui/emoji-picker"; import { EmojiIcon } from "@/client/components/ui/emoji-icon"; import { canChangeMemberRole, canLeaveWorkspace, canRemoveMember, isWorkspaceAdminOrOwnerRole, type ResolvedWorkspaceRole, } from "@/shared/entitlements"; import type { InviteRole, Workspace, WorkspaceMember, User, WorkspaceRole } from "@/shared/types"; const ROLE_BADGE: Record = { owner: { label: "Owner", className: "bg-amber-500/10 text-amber-400 border border-amber-500/20" }, admin: { label: "Admin", className: "bg-blue-500/10 text-blue-400 border border-blue-500/20" }, member: { label: "Member", className: "bg-zinc-500/10 text-zinc-400 border border-zinc-700" }, guest: { label: "Guest", className: "bg-zinc-500/10 text-zinc-500 border border-zinc-700" }, }; const ROLE_ICON: Record = { owner: Crown, admin: Shield, member: UserIcon, guest: UserIcon, }; const ASSIGNABLE_ROLES = ["admin", "member", "guest"] as const satisfies readonly InviteRole[]; export function WorkspaceSettings() { const params = useParams({ strict: false }) as { workspaceSlug?: string }; const currentWorkspace = useCurrentWorkspace(); const members = useWorkspaceMembers(); const role = useWorkspaceRole(); const currentUser = useAuthStore((s) => s.user); const isOwner = role === "owner"; const isAdminOrOwner = isWorkspaceAdminOrOwnerRole(role ?? "none"); const myRole: ResolvedWorkspaceRole = role ?? "none"; useDocumentTitle(currentWorkspace ? `Settings — ${currentWorkspace.name}` : "Settings"); if (!currentWorkspace) return null; return (
Back to {currentWorkspace.name}

Workspace Settings

{isOwner && } {myRole !== "guest" && myRole !== "none" && ( )} {canLeaveWorkspace(myRole) && } {isOwner && }
); } function ProfileSection({ workspace }: { workspace: Workspace }) { const [name, setName] = useState(workspace.name); const [icon, setIcon] = useState(workspace.icon ?? ""); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [iconPickerOpen, setIconPickerOpen] = useState(false); const iconTriggerRef = useRef(null); const hasChanges = name.trim() !== workspace.name || (icon.trim() || null) !== (workspace.icon ?? null); const handleSave = useCallback(async () => { if (saving) return; setSaving(true); setSaveError(null); try { const updated = await api.workspaces.update(workspace.id, { name: name.trim(), icon: icon.trim() || null, }); // `updated` is a plain Workspace (no role). Both stores take the plain // patch and preserve their role-aware fields. await replicaCommands.patchWorkspaceHead(workspace.id, updated); await directoryCommands.patch(workspace.id, updated); setName(updated.name); setIcon(updated.icon ?? ""); } catch (err) { setSaveError(toApiError(err).message); } finally { setSaving(false); } }, [workspace.id, name, icon, saving]); return (

Workspace Info

{icon && ( )}
setName(e.target.value)} 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" placeholder="Workspace name" /> {iconPickerOpen && ( setIconPickerOpen(false)} className="border-0 bg-transparent shadow-none" > { setIcon(emoji); setIconPickerOpen(false); }} /> )}
{saveError &&

{saveError}

}
); } interface MembersSectionProps { workspace: Workspace; members: WorkspaceMember[]; currentUser: User | null; myRole: ResolvedWorkspaceRole; } function MembersSection({ workspace, members, currentUser, myRole }: MembersSectionProps) { const [updatingRole, setUpdatingRole] = useState(null); const [removingMember, setRemovingMember] = useState(null); const [memberError, setMemberError] = useState(null); const handleRoleChange = useCallback( async (userId: string, newRole: string) => { setUpdatingRole(userId); setMemberError(null); try { await api.workspaces.updateMemberRole(workspace.id, userId, newRole); await replicaCommands.replaceMembers( workspace.id, members.map((m) => (m.user_id === userId ? { ...m, role: newRole as WorkspaceRole } : m)), ); } catch (err) { setMemberError(toApiError(err).message); } finally { setUpdatingRole(null); } }, [workspace.id, members], ); const handleRemoveMember = useCallback( async (userId: string, memberName: string) => { const ok = await confirm({ title: "Remove member", message: `${memberName} will lose access to this workspace.`, confirmLabel: "Remove", variant: "danger", }); if (!ok) return; setRemovingMember(userId); setMemberError(null); try { await api.workspaces.removeMember(workspace.id, userId); await replicaCommands.replaceMembers( workspace.id, members.filter((m) => m.user_id !== userId), ); } catch (err) { setMemberError(toApiError(err).message); } finally { setRemovingMember(null); } }, [workspace.id, members], ); return (

Members ({members.length})

{memberError &&

{memberError}

}
{members.map((member, memberIndex) => ( ))}
); } interface MemberRowProps { member: WorkspaceMember; memberIndex: number; currentUser: User | null; myRole: ResolvedWorkspaceRole; updatingRole: string | null; removingMember: string | null; onRoleChange: (userId: string, newRole: string) => void; onRemove: (userId: string, memberName: string) => void; } function MemberRow({ member, memberIndex, currentUser, myRole, updatingRole, removingMember, onRoleChange, onRemove, }: MemberRowProps) { const [roleDropdownOpen, setRoleDropdownOpen] = useState(false); const triggerRef = useRef(null); const user = member.user; const displayName = user?.name ?? "Unknown"; const displayEmail = user?.email ?? ""; const badge = ROLE_BADGE[member.role] ?? ROLE_BADGE.member; const RoleIcon = ROLE_ICON[member.role] ?? UserIcon; const isSelf = member.user_id === currentUser?.id; const isMemberOwner = member.role === "owner"; // Row-level affordance mirrors worker policy. `allowedRoles` filters // the dropdown so admins don't see options the worker will reject // (e.g. promote-to-admin). `canRemove` hides the delete action for // admin-vs-admin and owner-target cases. const allowedRoles = ASSIGNABLE_ROLES.filter( (option) => option !== member.role && canChangeMemberRole(myRole, member.role, option), ); const canChangeRole = !isSelf && allowedRoles.length > 0; const canRemove = !isSelf && !isMemberOwner && canRemoveMember(myRole, member.role, false); return (

{displayName} {isSelf && (you)}

{displayEmail &&

{displayEmail}

}
{canChangeRole ? ( <> {roleDropdownOpen && ( setRoleDropdownOpen(false)} className="py-1" > {allowedRoles.map((role) => ( ))} )} ) : ( {badge.label} )} {canRemove && (
); } function InviteSection({ workspace, isAdminOrOwner }: { workspace: Workspace; isAdminOrOwner: boolean }) { const [inviteRole, setInviteRole] = useState<"admin" | "member" | "guest">("member"); const [inviteEmail, setInviteEmail] = useState(""); const [inviteLoading, setInviteLoading] = useState(false); const [inviteLink, setInviteLink] = useState(null); const { copiedId: inviteCopied, copy: copyInviteToClipboard } = useCopyFeedback(); const [inviteError, setInviteError] = useState(null); const handleCreateInvite = useCallback(async () => { if (inviteLoading) return; setInviteLoading(true); setInviteError(null); setInviteLink(null); try { const invite = await api.invites.create(workspace.id, { role: inviteRole, email: inviteEmail.trim() || undefined, }); setInviteLink(`${window.location.origin}/invite/${invite.token}`); setInviteEmail(""); } catch (err) { setInviteError(toApiError(err).message); } finally { setInviteLoading(false); } }, [workspace.id, inviteRole, inviteEmail, inviteLoading]); const copyInviteLink = useCallback(() => { if (!inviteLink) return; copyInviteToClipboard("invite", inviteLink); }, [inviteLink, copyInviteToClipboard]); return (

Invite

setInviteEmail(e.target.value)} placeholder="Email (optional)" aria-label="Invite email address" 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" />
{inviteError &&

{inviteError}

} {inviteLink && (

{inviteLink}

)}
); } function LeaveWorkspaceSection({ workspace }: { workspace: Workspace }) { const navigate = useNavigate(); const currentUser = useAuthStore((s) => s.user); const [leaving, setLeaving] = useState(false); const handleLeave = useCallback(async () => { if (leaving || !currentUser) return; const ok = await confirm({ title: "Leave workspace", 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.`, confirmLabel: "Leave", variant: "danger", }); if (!ok) return; setLeaving(true); try { await api.workspaces.removeMember(workspace.id, currentUser.id); // Cascade across directory, replica, navigation, pageAccess. Prevents // root routing from redirecting the user back into a workspace they // just left. await workspaceLifecycleCommands.removeWorkspace(workspace.id); navigate({ to: "/" }); } catch (err) { toast.error(toApiError(err).message); setLeaving(false); } }, [workspace.id, workspace.name, currentUser, leaving, navigate]); return (

Leave workspace

Leave "{workspace.name}"

You can rejoin later if re-invited.

); } function DangerSection({ workspace }: { workspace: Workspace }) { const navigate = useNavigate(); const [deleting, setDeleting] = useState(false); const handleDeleteWorkspace = useCallback(async () => { if (deleting) return; const ok = await confirm({ title: "Delete workspace", message: `"${workspace.name}" and all its pages will be permanently deleted. This cannot be undone.`, confirmLabel: "Delete", variant: "danger", }); if (!ok) return; setDeleting(true); try { await api.workspaces.delete(workspace.id); await workspaceLifecycleCommands.removeWorkspace(workspace.id); navigate({ to: "/" }); } catch { toast.error("Failed to delete workspace"); setDeleting(false); } }, [workspace.id, workspace.name, deleting, navigate]); return (

Danger Zone

Delete this workspace

All pages and data will be permanently removed.

); }