From 61abb750367bceacf1e38c86d15f558c3e497cbd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 10:07:04 +0200 Subject: [PATCH 01/11] feat(settings): redesign the general tab with a save bar, data region and leave organization Name edits go through the settings save bar while the logo still saves on upload. The data region gets its own read-only section, and the danger zone lists what deletion removes, what ages out and what is kept, matched to the organization delete purge. Members can leave the organization unless they are its only admin. --- .../src/components/common/image-dropzone.tsx | 74 ++-- .../components/settings/general-section.tsx | 346 ++++++++++++++++++ .../settings/org-membership.test.ts | 30 ++ .../src/components/settings/org-membership.ts | 38 ++ .../settings/organization-section.tsx | 240 ------------ apps/web/src/routes/settings.tsx | 4 +- 6 files changed, 464 insertions(+), 268 deletions(-) create mode 100644 apps/web/src/components/settings/general-section.tsx create mode 100644 apps/web/src/components/settings/org-membership.test.ts create mode 100644 apps/web/src/components/settings/org-membership.ts delete mode 100644 apps/web/src/components/settings/organization-section.tsx diff --git a/apps/web/src/components/common/image-dropzone.tsx b/apps/web/src/components/common/image-dropzone.tsx index dd0f6f5859..0f7a84487d 100644 --- a/apps/web/src/components/common/image-dropzone.tsx +++ b/apps/web/src/components/common/image-dropzone.tsx @@ -26,6 +26,10 @@ interface ImageDropzoneProps { /** Label of the upload button, e.g. "Change picture". */ changeLabel: string hint?: ReactNode + /** Row layout: the title and hint sit beside the target and the actions move to the right. */ + title?: ReactNode + /** Shows the image only: no drop target, picker or actions. */ + readOnly?: boolean } /** Square click-or-drop image target with upload/remove actions, shared by avatar and logo settings. */ @@ -40,10 +44,12 @@ export function ImageDropzone({ targetLabel, changeLabel, hint = "Drop an image or click to upload. PNG, JPG, WEBP or GIF, up to 10 MB.", + title, + readOnly = false, }: ImageDropzoneProps) { const [isDragging, setIsDragging] = useState(false) const fileInputRef = useRef(null) - const interactive = !disabled && !uploading + const interactive = !disabled && !uploading && !readOnly function selectFile(file: File | undefined | null) { if (!interactive || !file) return @@ -79,13 +85,33 @@ export function ImageDropzone({ selectFile(e.dataTransfer.files?.[0]) } + const actions = ( +
+ + {onRemove && ( + + )} +
+ ) + return (
{ @@ -96,9 +122,11 @@ export function ImageDropzone({ onDrop={handleDrop} className={cn( "relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-md border border-dashed p-1 outline-none transition-colors", - interactive - ? "cursor-pointer hover:border-primary focus-visible:ring-2 focus-visible:ring-ring" - : "cursor-not-allowed opacity-60", + readOnly + ? null + : interactive + ? "cursor-pointer hover:border-primary focus-visible:ring-2 focus-visible:ring-ring" + : "cursor-not-allowed opacity-60", isDragging ? "border-primary ring-2 ring-primary" : "border-border", )} > @@ -109,26 +137,20 @@ export function ImageDropzone({
)}
-
-
- - {onRemove && ( - - )} + {title !== undefined ? ( + <> +
+
{title}
+ {hint &&

{hint}

} +
+ {readOnly ? null : actions} + + ) : ( +
+ {readOnly ? null : actions} + {hint &&

{hint}

}
- {hint &&

{hint}

} -
+ )} + {DELETION_FACTS.map((fact) => ( +
+
+ {fact.key} +
+
{fact.value}
+
+ ))} + + ) +} + +export function GeneralSection() { + const { organization, isLoaded } = useOrganization() + // Same params as AdminOnlyNotice, so both read one cached page; `count` is the admin total. + const { memberships: admins } = useOrganization({ memberships: { role: ["org:admin"], pageSize: 1 } }) + const { setActive, userMemberships } = useOrganizationList({ + userMemberships: { infinite: true }, + }) + const { user } = useUser() + const navigate = useNavigate() + + const isAdmin = useIsOrgAdmin() + + const nameDraft = useSettingsDraft(organization?.name ?? "") + const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) + const [withSavingLogo, isSavingLogo] = useAsyncAction((task: () => Promise) => task()) + const [deleteOpen, setDeleteOpen] = useState(false) + const [leaveOpen, setLeaveOpen] = useState(false) + const [confirmText, setConfirmText] = useState("") + const [withDeleting, isDeleting] = useAsyncAction((task: () => Promise) => task()) + const [withLeaving, isLeaving] = useAsyncAction((task: () => Promise) => task()) + + const deleteMutation = useAtomSet(MapleInternalAtomClient.mutation("organizations", "delete"), { + mode: "promiseExit", + }) + + if (!isLoaded) return + + if (!organization) { + return ( + + + + + + No organization + + Select or create an organization to manage its settings. + + + + ) + } + + const trimmedName = nameDraft.draft.trim() + const confirmMatches = confirmText.trim() === organization.name + const leave = leaveEligibility({ isAdmin, adminCount: admins?.count }) + + /** After the org is gone for this user, switch to another one (or none) and go home. */ + async function switchAway(orgId: string) { + const next = + (userMemberships?.data ?? []).find((m) => m.organization.id !== orgId)?.organization.id ?? null + // A failed switch falls through to navigation; Clerk's session refreshes on next load. + if (setActive) await setActive({ organization: next }).catch(() => undefined) + void navigate({ to: "/" }) + } + + function handleSaveName() { + if (!organization || trimmedName.length === 0) return + return withSavingName(async () => { + const ok = await settleClerk(organization.update({ name: trimmedName }), { + success: "Organization renamed", + error: "Failed to rename organization", + }) + if (ok) nameDraft.discard() + return ok + }) + } + + function handleLogoSelect(file: File) { + if (!organization || !isAdmin || isSavingLogo) return + return withSavingLogo(() => + settleClerk(organization.setLogo({ file }), { + success: "Organization logo updated", + error: "Failed to update logo", + }), + ) + } + + function handleRemoveLogo() { + if (!organization || !isAdmin || isSavingLogo) return + return withSavingLogo(() => + settleClerk(organization.setLogo({ file: null }), { + success: "Organization logo removed", + error: "Failed to remove logo", + }), + ) + } + + function handleDelete() { + if (!organization || !confirmMatches) return + return withDeleting(async () => { + const result = await deleteMutation({}) + if (toastExit(result, { error: "Failed to delete organization" })) { + toastManager.add({ title: "Organization deleted", type: "success" }) + setDeleteOpen(false) + setConfirmText("") + await switchAway(organization.id) + } + }) + } + + function handleLeave() { + if (!organization || !user || leave !== "allowed") return + return withLeaving(async () => { + const ok = await settleClerk(user.leaveOrganization(organization.id), { + success: `You left ${organization.name}`, + error: "Failed to leave organization", + }) + if (!ok) return + setLeaveOpen(false) + await switchAway(organization.id) + }) + } + + function handleDeleteDialogChange(open: boolean) { + setDeleteOpen(open) + if (!open) setConfirmText("") + } + + return ( + + + + +
+
+ + } + onFile={(file) => void handleLogoSelect(file)} + onRemove={organization.hasImage ? () => void handleRemoveLogo() : undefined} + uploading={isSavingLogo} + readOnly={!isAdmin} + targetLabel="Change organization logo" + changeLabel="Change logo" + /> +
+
+
+ +

+ Shown in the organization switcher. +

+
+ {isAdmin ? ( + nameDraft.setDraft(e.target.value)} + maxLength={100} + disabled={isSavingName} + placeholder="Organization name" + /> + ) : ( +

+ {organization.name} +

+ )} +
+
+
+ + + + + +
+
+

Leave organization

+

+ {leave === "last-admin" + ? "You are the only admin. Make another member an admin before you leave." + : "You lose access until someone invites you again."} +

+
+ +
+ {isAdmin ? ( +
+
+

Delete organization

+

+ Permanently deletes "{organization.name}" and removes every member's + access. This cannot be undone. +

+ +
+ +
+ ) : null} +
+
+ + void handleSaveName()} + /> + + void handleLeave()} + /> + + void handleDelete()} + > +
+ + +
+
+
+ ) +} + +function DataRegionSection({ metadata }: { metadata: unknown }) { + const region = organizationHomeRegion(metadata) + return ( + +
+
+ + {MAPLE_REGION_LABELS[region].name} +
+

+ Set when the organization was created. It cannot be changed. +

+
+
+ ) +} diff --git a/apps/web/src/components/settings/org-membership.test.ts b/apps/web/src/components/settings/org-membership.test.ts new file mode 100644 index 0000000000..08c3824c1f --- /dev/null +++ b/apps/web/src/components/settings/org-membership.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { leaveEligibility, orgRoleLabel, toOrgRole } from "./org-membership" + +describe("leaveEligibility", () => { + it("lets a member leave without waiting for the admin count", () => { + expect(leaveEligibility({ isAdmin: false, adminCount: undefined })).toBe("allowed") + expect(leaveEligibility({ isAdmin: false, adminCount: 1 })).toBe("allowed") + }) + + it("blocks the only admin", () => { + expect(leaveEligibility({ isAdmin: true, adminCount: 1 })).toBe("last-admin") + expect(leaveEligibility({ isAdmin: true, adminCount: 0 })).toBe("last-admin") + }) + + it("lets an admin leave when another admin remains", () => { + expect(leaveEligibility({ isAdmin: true, adminCount: 2 })).toBe("allowed") + }) + + it("holds an admin until the admin count is known", () => { + expect(leaveEligibility({ isAdmin: true, adminCount: undefined })).toBe("checking") + }) +}) + +describe("toOrgRole", () => { + it("reads unknown Clerk roles as member", () => { + expect(toOrgRole("org:admin")).toBe("org:admin") + expect(toOrgRole("org:billing")).toBe("org:member") + expect(orgRoleLabel("org:viewer")).toBe("Member") + }) +}) diff --git a/apps/web/src/components/settings/org-membership.ts b/apps/web/src/components/settings/org-membership.ts new file mode 100644 index 0000000000..e3a64570a9 --- /dev/null +++ b/apps/web/src/components/settings/org-membership.ts @@ -0,0 +1,38 @@ +export type OrgRole = "org:admin" | "org:member" + +/** Clerk role strings outside the two Maple roles read as member, the safer reading. */ +export function toOrgRole(role: string): OrgRole { + return role === "org:admin" ? "org:admin" : "org:member" +} + +export function orgRoleLabel(role: string): string { + return toOrgRole(role) === "org:admin" ? "Admin" : "Member" +} + +/** + * Whether the signed-in user may leave the organization. The last admin cannot: the org would be + * left with nobody able to manage it. `adminCount` is undefined while the membership list loads. + */ +export type LeaveEligibility = "allowed" | "checking" | "last-admin" + +export function leaveEligibility(input: { + readonly isAdmin: boolean + readonly adminCount: number | undefined +}): LeaveEligibility { + if (!input.isAdmin) return "allowed" + if (input.adminCount === undefined) return "checking" + return input.adminCount > 1 ? "allowed" : "last-admin" +} + +/** The confirm-strip question before a role change, naming what the new role can or cannot do. */ +export function roleChangePrompt(name: string, next: OrgRole): { question: string; confirmLabel: string } { + return next === "org:admin" + ? { + question: `Make ${name} an admin? Admins can manage billing and delete the organization.`, + confirmLabel: "Make admin", + } + : { + question: `Make ${name} a member? Members can no longer manage billing, keys or members.`, + confirmLabel: "Make member", + } +} diff --git a/apps/web/src/components/settings/organization-section.tsx b/apps/web/src/components/settings/organization-section.tsx deleted file mode 100644 index c25429bb59..0000000000 --- a/apps/web/src/components/settings/organization-section.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { useAtomSet } from "@/lib/effect-atom" -import { useEffect, useState } from "react" -import { useNavigate } from "@tanstack/react-router" -import { useOrganization, useOrganizationList } from "@clerk/clerk-react" -import { toastManager } from "@maple/ui/components/ui/toast" -import { Panel } from "@maple/ui/components/ui/panel" -import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" - -import { Button } from "@maple/ui/components/ui/button" -import { Input } from "@maple/ui/components/ui/input" -import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { UserIcon } from "@/components/icons" -import { ImageDropzone, TypeToConfirmField } from "@/components/common/image-dropzone" -import { settleClerk } from "@/components/account/account-errors" -import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" -import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" -import { toastExit } from "@/lib/error-toast" -import { OrgAvatar } from "@/components/dashboard/org-switcher-menu" -import { RegionBadge } from "@/components/region/region-badge" -import { organizationHomeRegion } from "@maple/domain/organization-regions" -import { MAPLE_REGION_LABELS } from "@/lib/region" -import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" -import { useAsyncAction } from "@/hooks/use-mutation-action" -import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" - -export function OrganizationSection() { - const { organization, isLoaded } = useOrganization() - const { setActive, userMemberships } = useOrganizationList({ - userMemberships: { infinite: true }, - }) - const navigate = useNavigate() - - const isAdmin = useIsOrgAdmin() - - const [name, setName] = useState("") - const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) - const [withSavingLogo, isSavingLogo] = useAsyncAction((task: () => Promise) => task()) - const [deleteOpen, setDeleteOpen] = useState(false) - const [confirmText, setConfirmText] = useState("") - const [withDeleting, isDeleting] = useAsyncAction((task: () => Promise) => task()) - - useEffect(() => { - setName(organization?.name ?? "") - }, [organization?.id, organization?.name]) - - const deleteMutation = useAtomSet(MapleInternalAtomClient.mutation("organizations", "delete"), { - mode: "promiseExit", - }) - - if (!isLoaded) return - - if (!organization) { - return ( - - - - - - No organization - - Select or create an organization to manage its settings. - - - - ) - } - - const trimmedName = name.trim() - const nameDirty = trimmedName.length > 0 && trimmedName !== organization.name - const confirmMatches = confirmText.trim() === organization.name - - function handleRename() { - if (!organization || !nameDirty) return - return withSavingName(() => - settleClerk(organization.update({ name: trimmedName }), { - success: "Organization renamed", - error: "Failed to rename organization", - }), - ) - } - - function handleLogoSelect(file: File) { - if (!organization || !isAdmin || isSavingLogo) return - return withSavingLogo(() => - settleClerk(organization.setLogo({ file }), { - success: "Organization logo updated", - error: "Failed to update logo", - }), - ) - } - - function handleRemoveLogo() { - if (!organization || !isAdmin || isSavingLogo) return - return withSavingLogo(() => - settleClerk(organization.setLogo({ file: null }), { - success: "Organization logo removed", - error: "Failed to remove logo", - }), - ) - } - - function handleDelete() { - if (!organization || !confirmMatches) return - return withDeleting(async () => { - const result = await deleteMutation({}) - if (toastExit(result, { error: "Failed to delete organization" })) { - const remaining = (userMemberships?.data ?? []).filter( - (m) => m.organization.id !== organization.id, - ) - const next = remaining[0]?.organization.id ?? null - // A failed switch falls through to navigation; Clerk's session refreshes on next load. - if (setActive) await setActive({ organization: next }).catch(() => undefined) - toastManager.add({ title: "Organization deleted", type: "success" }) - setDeleteOpen(false) - setConfirmText("") - navigate({ to: "/" }) - } - }) - } - - function handleDialogChange(open: boolean) { - setDeleteOpen(open) - if (!open) setConfirmText("") - } - - return ( - - -
- - Logo - - } - onFile={(file) => void handleLogoSelect(file)} - onRemove={organization.hasImage ? () => void handleRemoveLogo() : undefined} - uploading={isSavingLogo} - disabled={!isAdmin} - targetLabel="Change organization logo" - changeLabel="Change logo" - /> - - - Name - setName(e.target.value)} - maxLength={100} - disabled={!isAdmin || isSavingName} - placeholder="Organization name" - /> - -
- -
- -
-
- - - -

- {isAdmin - ? `Delete "${organization.name}" and remove every member's access.` - : "Only org admins can delete the organization."} -

- -
-
- - void handleDelete()} - > - - -
- ) -} - -function DataRegionRow({ metadata }: { metadata: unknown }) { - const region = organizationHomeRegion(metadata) - return ( - - Data region -
- - {MAPLE_REGION_LABELS[region].name} -
- - Chosen when the organization was created. All of its telemetry is stored and processed in this - region. - -
- ) -} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a51f3b08af..eeced8a552 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -14,7 +14,7 @@ import { McpSection } from "@/components/settings/mcp-section" import { AiProviderSection } from "@/components/settings/ai-provider-section" import { AutomationSection } from "@/components/settings/automation-section" import { OrgClickHouseSettingsSection } from "@/components/settings/org-clickhouse-settings-section" -import { OrganizationSection } from "@/components/settings/organization-section" +import { GeneralSection } from "@/components/settings/general-section" import { SetupAuditSection } from "@/components/settings/setup-audit-section" import { legacySettingsTabValues, @@ -101,7 +101,7 @@ function SettingsPage() { No settings are available for the current account. ) : ( <> - {activeTab === "general" && } + {activeTab === "general" && } {activeTab === "members" && } {activeTab === "audit-log" && } {activeTab === "setup-health" && } From 8700af9a35072c041f9ceda35fa3ffff407b677b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 10:09:09 +0200 Subject: [PATCH 02/11] feat(settings): redesign the members tab with inline role confirm and invitation actions Members use the wide width with one line on what admins manage. A role select on each row opens a confirm strip before the change, and the row menu keeps only Remove, hidden on your own row. Invitations show when they were sent and share the same row menu with Resend (revoke and re-invite with the same role) and Revoke. The status column is gone. --- .../components/settings/members-section.tsx | 406 +++++++++++------- 1 file changed, 246 insertions(+), 160 deletions(-) diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index d92857633e..ecce09d53a 100644 --- a/apps/web/src/components/settings/members-section.tsx +++ b/apps/web/src/components/settings/members-section.tsx @@ -1,6 +1,6 @@ -// BOUNDARY: This module intentionally carries opaque values; callers decode them before domain use. +// BOUNDARY: Clerk resource methods resolve to opaque results that are only awaited, never read. import { useOrganization, useAuth } from "@clerk/clerk-react" -import { useState } from "react" +import { Fragment, useState } from "react" import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" @@ -16,8 +16,9 @@ import { import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" import { initialsFrom } from "@maple/ui/lib/initials" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" import { Avatar, AvatarFallback, AvatarImage } from "@maple/ui/components/ui/avatar" -import { Badge } from "@maple/ui/components/ui/badge" import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" @@ -28,18 +29,43 @@ import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { settleClerk } from "@/components/account/account-errors" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { AdminOnlyNotice, memberDisplayName } from "@/components/settings/admin-only-notice" +import { orgRoleLabel, roleChangePrompt, toOrgRole, type OrgRole } from "@/components/settings/org-membership" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { PlusIcon, TrashIcon, ShieldIcon, UserIcon, EnvelopeIcon } from "@/components/icons" +import { + ArrowRotateAnticlockwiseIcon, + CircleInfoIcon, + EnvelopeIcon, + PlusIcon, + TrashIcon, + UserIcon, +} from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatDateInTimezone } from "@/lib/timezone-format" import { useAsyncAction } from "@/hooks/use-mutation-action" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" -function roleBadge(role: string) { - if (role === "org:admin") { - return Admin - } - return Member +/** A role change waiting on the inline confirm strip under the member's row. */ +interface PendingRoleChange { + readonly memberId: string + readonly name: string + readonly next: OrgRole + readonly update: (params: { role: OrgRole }) => Promise +} + +interface MemberToRemove { + readonly name: string + readonly destroy: () => Promise +} + +interface InvitationTarget { + readonly email: string + readonly role: OrgRole + readonly revoke: () => Promise +} + +function RoleText({ role }: { role: string }) { + return {orgRoleLabel(role)} } export function MembersSection() { @@ -51,22 +77,18 @@ export function MembersSection() { const [inviteOpen, setInviteOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState("") - const [inviteRole, setInviteRole] = useState("org:member") + const [inviteRole, setInviteRole] = useState("org:member") const [withInviteLoading, inviteLoading] = useAsyncAction((task: () => Promise) => task()) - const [removeDialogOpen, setRemoveDialogOpen] = useState(false) - const [memberToRemove, setMemberToRemove] = useState<{ - id: string - name: string - destroy: () => Promise - } | null>(null) + const [pendingRole, setPendingRole] = useState(null) + const [withRoleSaving, roleSaving] = useAsyncAction((task: () => Promise) => task()) + + const [memberToRemove, setMemberToRemove] = useState(null) const [withRemoveLoading, removeLoading] = useAsyncAction((task: () => Promise) => task()) - const [invitationToRevoke, setInvitationToRevoke] = useState<{ - email: string - revoke: () => Promise - } | null>(null) + const [invitationToRevoke, setInvitationToRevoke] = useState(null) const [withRevokeLoading, revokeLoading] = useAsyncAction((task: () => Promise) => task()) + const [withResending] = useAsyncAction((task: () => Promise) => task()) const { effectiveTimezone } = useTimezonePreference() const isAdmin = useIsOrgAdmin() @@ -75,30 +97,28 @@ export function MembersSection() { if (!organization || !inviteEmail.trim()) return return withInviteLoading(async () => { const ok = await settleClerk( - organization.inviteMember({ - emailAddress: inviteEmail.trim(), - role: inviteRole === "org:admin" ? "org:admin" : "org:member", - }), + organization.inviteMember({ emailAddress: inviteEmail.trim(), role: inviteRole }), { success: `Invitation sent to ${inviteEmail}`, error: "Failed to send invitation" }, ) if (!ok) return setInviteEmail("") setInviteRole("org:member") setInviteOpen(false) - invitations?.revalidate?.() + void invitations?.revalidate?.() }) } - async function handleRoleChange( - currentRole: string, - update: (params: { role: string }) => Promise, - ) { - const newRole = currentRole === "org:admin" ? "org:member" : "org:admin" - const ok = await settleClerk(update({ role: newRole }), { - success: `Role updated to ${newRole === "org:admin" ? "Admin" : "Member"}`, - error: "Failed to update role", + function handleConfirmRole() { + if (!pendingRole) return + return withRoleSaving(async () => { + const ok = await settleClerk(pendingRole.update({ role: pendingRole.next }), { + success: `${pendingRole.name} is now ${pendingRole.next === "org:admin" ? "an admin" : "a member"}`, + error: "Failed to change role", + }) + if (!ok) return + setPendingRole(null) + void memberships?.revalidate?.() }) - if (ok) memberships?.revalidate?.() } function handleRemoveMember() { @@ -110,8 +130,7 @@ export function MembersSection() { error: "Failed to remove member", }) if (!ok) return - memberships?.revalidate?.() - setRemoveDialogOpen(false) + void memberships?.revalidate?.() setMemberToRemove(null) }) } @@ -124,11 +143,25 @@ export function MembersSection() { error: "Failed to revoke invitation", }) if (!ok) return - invitations?.revalidate?.() + void invitations?.revalidate?.() setInvitationToRevoke(null) }) } + /** Clerk has no resend: revoke the old invitation, then invite the same email with the same role. */ + function handleResend(target: InvitationTarget) { + if (!organization) return + return withResending(async () => { + const revoked = await settleClerk(target.revoke(), { error: "Failed to resend invitation" }) + if (!revoked) return + await settleClerk(organization.inviteMember({ emailAddress: target.email, role: target.role }), { + success: `Invitation resent to ${target.email}`, + error: `The old invitation was revoked but a new one could not be sent. Invite ${target.email} again.`, + }) + void invitations?.revalidate?.() + }) + } + if (!isLoaded) { return ( @@ -151,24 +184,21 @@ export function MembersSection() { if (!organization) { return ( -
- - - - - - No organization - - Select or create an organization to manage members. - - - -
+ + + + + + No organization + Select or create an organization to manage members. + + ) } const memberList = memberships?.data ?? [] const invitationList = invitations?.data ?? [] + const columnCount = isAdmin ? 4 : 3 const inviteButton = isAdmin ? ( + +
+ + + + )} + + ) + })} + + {memberships?.hasNextPage && (
0 && ( Email - Role - Status - {isAdmin && } + Role + Invited + {isAdmin && } - {invitationList.map((invitation) => ( - - -
- - - - - - -
-
- {roleBadge(invitation.role)} - - Pending - - {isAdmin && ( + {invitationList.map((invitation) => { + const target: InvitationTarget = { + email: invitation.emailAddress, + role: toOrgRole(invitation.role), + revoke: () => invitation.revoke(), + } + return ( + + +
+ + + + + + +
+
- + - )} -
- ))} + + {formatRelativeTime(invitation.createdAt)} + + {isAdmin && ( + + + void handleResend(target)} + > + + Resend + + setInvitationToRevoke(target)} + > + + Revoke + + + + )} +
+ ) + })}
{invitations?.hasNextPage && ( @@ -388,11 +476,9 @@ export function MembersSection() { Role - val && setInviteRole(toOrgRole(val))}> - - {inviteRole === "org:admin" ? "Admin" : "Member"} - + {orgRoleLabel(inviteRole)} Member @@ -413,10 +499,10 @@ export function MembersSection() { /> (open ? undefined : setMemberToRemove(null))} title="Remove member?" - description={`${memberToRemove?.name ?? ""} will lose access to this organization immediately. This action cannot be undone.`} + description={`${memberToRemove?.name ?? ""} will lose access to this organization immediately. You can invite them again later.`} confirmLabel="Remove member" pending={removeLoading} onConfirm={() => void handleRemoveMember()} From 98b87097d49c57d6a78a130ce37385c010a3d5a6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 11:02:17 +0200 Subject: [PATCH 03/11] fix(settings): show skeleton rows while members load --- .../components/settings/members-section.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index ecce09d53a..6e73f185cf 100644 --- a/apps/web/src/components/settings/members-section.tsx +++ b/apps/web/src/components/settings/members-section.tsx @@ -199,6 +199,8 @@ export function MembersSection() { const memberList = memberships?.data ?? [] const invitationList = invitations?.data ?? [] const columnCount = isAdmin ? 4 : 3 + const membersLoading = + memberList.length === 0 && (memberships?.isLoading === true || memberships?.isFetching === true) const inviteButton = isAdmin ? (