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) { + // The org is gone, so a dirty name must not trip the unsaved changes guard on the way out. + nameDraft.discard() + 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: "/", ignoreBlocker: true }) + } + + function handleSaveName() { + if (!organization || trimmedName.length === 0) return + const sent = trimmedName + return withSavingName(async () => { + const submitted = nameDraft.submit() + const ok = await settleClerk(organization.update({ name: sent }), { + success: "Organization renamed", + error: "Failed to rename organization", + }) + if (ok) nameDraft.commit({ submitted, sent }) + else nameDraft.abort() + 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/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index d92857633e..2e298159e4 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,44 @@ 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 { useAsyncAction, useKeyedAsyncAction } 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 id: string + readonly role: OrgRole + readonly revoke: () => Promise +} + +function RoleText({ role }: { role: string }) { + return {orgRoleLabel(role)} } export function MembersSection() { @@ -51,22 +78,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 resending = useKeyedAsyncAction((_invitationId: string, task: () => Promise) => task()) const { effectiveTimezone } = useTimezonePreference() const isAdmin = useIsOrgAdmin() @@ -75,30 +98,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 +131,7 @@ export function MembersSection() { error: "Failed to remove member", }) if (!ok) return - memberships?.revalidate?.() - setRemoveDialogOpen(false) + void memberships?.revalidate?.() setMemberToRemove(null) }) } @@ -124,11 +144,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 || resending.isPending(target.id)) return + return resending.run(target.id, 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 +185,23 @@ 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 membersLoading = + memberList.length === 0 && (memberships?.isLoading === true || memberships?.isFetching === true) 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 = { + id: invitation.id, + email: invitation.emailAddress, + role: toOrgRole(invitation.role), + revoke: () => invitation.revoke(), + } + const isResending = resending.isPending(invitation.id) + return ( + + +
+ + + + + + +
+
- + - )} -
- ))} + + {formatRelativeTime(invitation.createdAt)} + + {isAdmin && ( + + + void handleResend(target)} + > + + {isResending ? "Resending" : "Resend"} + + setInvitationToRevoke(target)} + > + + Revoke + + + + )} +
+ ) + })}
{invitations?.hasNextPage && ( @@ -388,11 +504,9 @@ export function MembersSection() { Role - val && setInviteRole(toOrgRole(val))}> - - {inviteRole === "org:admin" ? "Admin" : "Member"} - + {orgRoleLabel(inviteRole)} Member @@ -413,10 +527,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()} 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" && } diff --git a/knip.json b/knip.json index 07f913de47..25bc4c41d6 100644 --- a/knip.json +++ b/knip.json @@ -1,13 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "ignoreWorkspaces": ["apps/ingest", "apps/ios", "examples/*"], - "ignore": [ - ".deepsec/**", - "**/*.type-test.ts", - // Shared settings pieces first used by the General tab in the next layer of the stack. - "apps/web/src/components/settings/settings-save-bar.tsx", - "apps/web/src/components/settings/admin-only-notice.tsx" - ], + "ignore": [".deepsec/**", "**/*.type-test.ts"], "ignoreExportsUsedInFile": true, "workspaces": { ".": {