From 423c658be84d6c9972832bbde9d8b64d9aba54e0 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:02:37 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/scope-selector/ScopePreview.tsx | 54 ++++ .../v2/scope-selector/ScopeSelector.tsx | 290 ++++++++++++++++++ .../src/components/v2/scope-selector/index.ts | 2 + vite/src/components/v2/selects/RoleSelect.tsx | 129 ++++++++ vite/src/hooks/useScopes.ts | 20 ++ vite/src/lib/auth-client.ts | 3 +- vite/src/services/DevService.tsx | 5 +- .../views/developer/api-keys/ApiKeysPage.tsx | 4 +- .../components/APIKeyTableColumns.tsx | 10 + .../components/CreateApiKeyDialog.tsx | 243 --------------- .../api-keys/components/CreateApiKeySheet.tsx | 266 ++++++++++++++++ vite/src/views/main-sidebar/MainSidebar.tsx | 23 +- .../org-dropdown/manage-org/InvitePopover.tsx | 72 ++++- .../manage-org/MemberRowToolbar.tsx | 42 +-- .../manage-org/OrgInvitesList.tsx | 11 +- .../manage-org/OrgMembersList.tsx | 93 +++--- vite/tsconfig.app.json | 1 + 17 files changed, 947 insertions(+), 321 deletions(-) create mode 100644 vite/src/components/v2/scope-selector/ScopePreview.tsx create mode 100644 vite/src/components/v2/scope-selector/ScopeSelector.tsx create mode 100644 vite/src/components/v2/scope-selector/index.ts create mode 100644 vite/src/components/v2/selects/RoleSelect.tsx create mode 100644 vite/src/hooks/useScopes.ts delete mode 100644 vite/src/views/developer/api-keys/components/CreateApiKeyDialog.tsx create mode 100644 vite/src/views/developer/api-keys/components/CreateApiKeySheet.tsx diff --git a/vite/src/components/v2/scope-selector/ScopePreview.tsx b/vite/src/components/v2/scope-selector/ScopePreview.tsx new file mode 100644 index 000000000..87317c256 --- /dev/null +++ b/vite/src/components/v2/scope-selector/ScopePreview.tsx @@ -0,0 +1,54 @@ +import { + groupAndFormatScopes, + type ScopeActionType, +} from "@autumn/shared"; +import { Badge } from "@/components/v2/badges/Badge"; + +export type ScopePreviewProps = { + scopes: string[] | null | undefined; + /** Render empty/null as a specific label. Default: "Full access (unrestricted)". */ + emptyLabel?: string; +}; + +/** + * Format a sorted action list into a compact badge label: + * ["read"] -> "R" + * ["write"] -> "W" + * ["read", "write"] -> "R+W" + */ +function formatActionsCompact(actions: ScopeActionType[]): string { + const hasRead = actions.includes("read"); + const hasWrite = actions.includes("write"); + if (hasRead && hasWrite) return "R+W"; + if (hasWrite) return "W"; + if (hasRead) return "R"; + return ""; +} + +export function ScopePreview({ + scopes, + emptyLabel = "Full access (unrestricted)", +}: ScopePreviewProps) { + if (!scopes || scopes.length === 0) { + return {emptyLabel}; + } + + const grouped = groupAndFormatScopes(scopes); + + // If the input contained only unknown/OpenID scopes, groupAndFormatScopes + // returns an empty list. Fall back to the empty label to avoid rendering + // nothing silently. + if (grouped.length === 0) { + return {emptyLabel}; + } + + return ( +
+ {grouped.map((g) => ( + + {g.resourceName}: {formatActionsCompact(g.actions)} + + ))} +
+ ); +} diff --git a/vite/src/components/v2/scope-selector/ScopeSelector.tsx b/vite/src/components/v2/scope-selector/ScopeSelector.tsx new file mode 100644 index 000000000..3d248eb8d --- /dev/null +++ b/vite/src/components/v2/scope-selector/ScopeSelector.tsx @@ -0,0 +1,290 @@ +import { + expandScopes, + groupAndFormatScopes, + RESOURCE_METADATA, + RESOURCES, + type ResourceType, + Scopes, + type ScopeString, +} from "@autumn/shared"; +import { useMemo, useState } from "react"; +import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; +import { ConditionalTooltip } from "@/components/v2/tooltips/ConditionalTooltip"; +import { cn } from "@/lib/utils"; + +export type ScopeSelectorProps = { + /** Current scopes. Empty array = unrestricted (all scopes granted). */ + value: ScopeString[]; + onChange: (scopes: ScopeString[]) => void; + /** + * Optional: the caller's own scopes. If provided, any scope NOT in this + * set is disabled with a tooltip explaining the caller can't grant it. + */ + availableScopes?: readonly string[]; + disabled?: boolean; +}; + +type TriState = "none" | "read" | "write"; + +type TriOption = { value: TriState; label: string }; + +const TRI_OPTIONS_FULL: TriOption[] = [ + { value: "none", label: "None" }, + { value: "read", label: "Read" }, + { value: "write", label: "Write" }, +]; + +const UNAVAILABLE_TOOLTIP = + "You don't have this scope on your current session"; +const READ_ONLY_RESOURCE_TOOLTIP = + "This resource is read-only — no write scope exists"; + +function deriveTriState( + value: readonly ScopeString[], + resource: ResourceType, +): TriState { + const write = `${resource}:write` as ScopeString; + const read = `${resource}:read` as ScopeString; + if (value.includes(write)) return "write"; + if (value.includes(read)) return "read"; + return "none"; +} + +function applyTriState( + value: readonly ScopeString[], + resource: ResourceType, + next: TriState, +): ScopeString[] { + const write = `${resource}:write`; + const read = `${resource}:read`; + const filtered = value.filter((s) => s !== write && s !== read); + if (next === "read") filtered.push(read as ScopeString); + if (next === "write") filtered.push(write as ScopeString); + return filtered; +} + +/** + * Tri-state action picker that mirrors the visual language of + * `GroupedTabButton` but supports per-option disabling (with tooltip). + * + * `GroupedTabButton` only supports group-level `disabled`, which is not + * sufficient for the "W unavailable but R allowed" case required by + * `availableScopes`. Class names are intentionally kept in sync with + * `GroupedTabButton` so this renders identically. + */ +function TriStatePicker({ + options, + value, + onChange, + readEnabled, + writeEnabled, + writeUnavailableReason, + disabled, +}: { + options: TriOption[]; + value: TriState; + onChange: (next: TriState) => void; + readEnabled: boolean; + writeEnabled: boolean; + /** + * Override tooltip text for a disabled `write` option. Used for the + * analytics resource, which has no write scope at all (distinct from + * "caller can't grant it"). + */ + writeUnavailableReason?: string | null; + disabled: boolean; +}) { + return ( + // Fixed width so every row's picker column is the same size. 3 + // segments × 72px ≈ 216px keeps "Read"/"Write" labels readable. +
+ {options.map((option, index) => { + const isActive = value === option.value; + const isFirst = index === 0; + const isLast = index === options.length - 1; + + let optionDisabled = disabled; + let tooltip: string | null = null; + if (option.value === "write" && !writeEnabled) { + optionDisabled = true; + tooltip = writeUnavailableReason ?? UNAVAILABLE_TOOLTIP; + } else if (option.value === "read" && !readEnabled) { + optionDisabled = true; + tooltip = UNAVAILABLE_TOOLTIP; + } + + const button = ( + + ); + + return ( + + {/* Wrap in span so Radix can attach listeners even when the button is disabled. */} + {button} + + ); + })} +
+ ); +} + +export function ScopeSelector({ + value, + onChange, + availableScopes, + disabled = false, +}: ScopeSelectorProps) { + // Restricted mode is a local UI concern. We seed it from the initial + // `value` length so a key that already has scopes opens in restricted + // mode, but we intentionally do NOT re-sync with `value` on every + // render — otherwise the user toggling all scopes to "None" would + // flip the checkbox off and lose the grid. + const [restricted, setRestricted] = useState(value.length > 0); + + const expandedAvailable = useMemo( + () => (availableScopes ? expandScopes(availableScopes) : null), + [availableScopes], + ); + + const isScopeAvailable = (scope: ScopeString): boolean => { + if (!expandedAvailable) return true; + // The `admin` meta-scope is a product-level bypass that grants + // every modern R/W scope. Without this short-circuit, a caller + // whose session only carries `admin` would see every row as + // unavailable, which is the opposite of the truth. + if (expandedAvailable.has("admin")) return true; + return expandedAvailable.has(scope); + }; + + const handleToggleRestricted = (checked: boolean) => { + setRestricted(checked); + if (!checked) { + onChange([]); + return; + } + if (value.length === 0) { + onChange([Scopes.Customers.Read]); + } + }; + + const summary = useMemo(() => { + const grouped = groupAndFormatScopes(value); + return { scopeCount: value.length, resourceCount: grouped.length }; + }, [value]); + + return ( +
+ + + {restricted && ( +
+ {RESOURCES.map((resource) => { + const meta = RESOURCE_METADATA[resource]; + const isAnalytics = resource === "analytics"; + + const readScope = `${resource}:read` as ScopeString; + const readAvailable = isScopeAvailable(readScope); + const writeAvailable = isAnalytics + ? false + : isScopeAvailable( + `${resource}:write` as ScopeString, + ); + + const fullyUnavailable = + !!expandedAvailable && + !readAvailable && + (isAnalytics || !writeAvailable); + + const triValue = deriveTriState(value, resource); + // Always render 3 segments so every row has the same + // width. For analytics, `Write` is permanently disabled + // with an explanatory tooltip. + const writeReason = isAnalytics + ? READ_ONLY_RESOURCE_TOOLTIP + : null; + + return ( +
+ + + {meta.namePlural} + + + + + onChange(applyTriState(value, resource, next)) + } + readEnabled={readAvailable} + writeEnabled={writeAvailable} + writeUnavailableReason={writeReason} + disabled={disabled} + /> +
+ ); + })} + +
+ Granting:{" "} + + {summary.scopeCount} scope + {summary.scopeCount === 1 ? "" : "s"} + {" "} + across{" "} + + {summary.resourceCount} resource + {summary.resourceCount === 1 ? "" : "s"} + +
+
+ )} +
+ ); +} diff --git a/vite/src/components/v2/scope-selector/index.ts b/vite/src/components/v2/scope-selector/index.ts new file mode 100644 index 000000000..60193dc26 --- /dev/null +++ b/vite/src/components/v2/scope-selector/index.ts @@ -0,0 +1,2 @@ +export * from "./ScopePreview"; +export * from "./ScopeSelector"; diff --git a/vite/src/components/v2/selects/RoleSelect.tsx b/vite/src/components/v2/selects/RoleSelect.tsx new file mode 100644 index 000000000..aae7f6c9b --- /dev/null +++ b/vite/src/components/v2/selects/RoleSelect.tsx @@ -0,0 +1,129 @@ +import type { Role } from "@autumn/shared"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./Select"; + +/** + * Role display metadata. + * + * Descriptions enumerate every resource grant explicitly, derived from + * `ROLE_SCOPES` in `shared/utils/scopeDefinitions.ts`. Keep these in sync + * with that table — if a role's grants change there, update here. + * + * Convention: + * - Write access implies read (expanded at check time), so "Write" in + * the description subsumes Read. + * - Resources not listed in the description are NOT granted. + */ +const ROLE_META: Record = { + owner: { + label: "Owner", + description: + "Write on everything (organisation, customers, features, plans, rewards, balances, billing, API keys, platform) + read analytics. Can delete the org and manage ownership.", + }, + admin: { + label: "Admin", + description: + "Write on everything (organisation, customers, features, plans, rewards, balances, billing, API keys, platform) + read analytics. Cannot delete the org or transfer ownership.", + }, + developer: { + label: "Developer", + description: + "Write on customers, features, plans, balances, billing, API keys, and platform. Read organisation and analytics. No access to rewards.", + }, + sales: { + label: "Sales", + description: + "Write on customers, billing, rewards, and balances. Read plans, features, and analytics. No access to organisation settings, API keys, or platform.", + }, + member: { + label: "Member", + description: + "Read-only on everything: organisation, customers, features, plans, rewards, balances, billing, analytics, API keys, platform. No write access.", + }, +}; + +const DEFAULT_ALLOWED: Role[] = ["admin", "developer", "sales", "member"]; + +export type RoleSelectProps = { + value: Role; + onChange: (role: Role) => void; + /** + * Allowed role choices. Defaults to all roles EXCEPT owner (you can't + * invite someone as owner; ownership transfer is a separate flow). + * Pass the full list if you need owner included (e.g. when displaying + * an existing owner's current role — though the Select will be + * disabled in that case). + */ + allowed?: Role[]; + disabled?: boolean; + /** Optional: disabled reason for tooltip display (use with ConditionalTooltip externally). */ + placeholder?: string; + /** Optional: className passthrough for the SelectTrigger. */ + className?: string; +}; + +export function RoleSelect({ + value, + onChange, + allowed = DEFAULT_ALLOWED, + disabled, + placeholder, + className, +}: RoleSelectProps) { + // Radix `SelectValue` normally projects the selected item's children + // into the trigger. Because each item renders `label + description`, + // that would push the description into the cramped trigger area. We + // override the projection by passing an explicit `children` prop to + // `SelectValue`, showing only the label on the trigger while the + // dropdown itself keeps the rich layout. + const selectedLabel = ROLE_META[value]?.label ?? value; + + return ( + + ); +} + +// Export the metadata so callers can render role display names without +// instantiating a dropdown (e.g. for read-only member rows). +export { ROLE_META }; +export type { Role }; diff --git a/vite/src/hooks/useScopes.ts b/vite/src/hooks/useScopes.ts new file mode 100644 index 000000000..9be4500c7 --- /dev/null +++ b/vite/src/hooks/useScopes.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { useSession } from "@/lib/auth-client"; +import { makeScopeChecker } from "@autumn/shared"; + +/** + * React wrapper around `makeScopeChecker` that reads scopes from the + * current dashboard session. Scopes are injected onto the session by the + * `customSession` better-auth plugin (see `server/src/utils/auth.ts`). + * + * Returns the same shape as `makeScopeChecker`: + * `{ expanded, isAdmin, isSuperuser, has, hasAny, hasAll, check }` + */ +export function useScopes() { + const { data: session } = useSession(); + + return useMemo(() => { + const raw = ((session as any)?.scopes ?? []) as string[]; + return makeScopeChecker(raw); + }, [session]); +} diff --git a/vite/src/lib/auth-client.ts b/vite/src/lib/auth-client.ts index 9e102d6ea..2423a8c6b 100644 --- a/vite/src/lib/auth-client.ts +++ b/vite/src/lib/auth-client.ts @@ -1,3 +1,4 @@ +import { ac, roles } from "@autumn/shared"; import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { adminClient, @@ -10,7 +11,7 @@ export const authClient = createAuthClient({ baseURL: import.meta.env.VITE_BACKEND_URL, plugins: [ emailOTPClient(), - organizationClient(), + organizationClient({ ac, roles }), adminClient(), oauthProviderClient(), ], diff --git a/vite/src/services/DevService.tsx b/vite/src/services/DevService.tsx index 795c014b9..c64f58487 100644 --- a/vite/src/services/DevService.tsx +++ b/vite/src/services/DevService.tsx @@ -1,7 +1,10 @@ import type { AxiosInstance } from "axios"; export class DevService { - static async createAPIKey(axiosInstance: AxiosInstance, data: any) { + static async createAPIKey( + axiosInstance: AxiosInstance, + data: { name: string; scopes?: string[] }, + ) { const { data: resBody } = await axiosInstance.post("/dev/api_key", data); return resBody; } diff --git a/vite/src/views/developer/api-keys/ApiKeysPage.tsx b/vite/src/views/developer/api-keys/ApiKeysPage.tsx index 8f3db2786..ba8e29411 100644 --- a/vite/src/views/developer/api-keys/ApiKeysPage.tsx +++ b/vite/src/views/developer/api-keys/ApiKeysPage.tsx @@ -6,7 +6,7 @@ import { EmptyState } from "@/components/v2/empty-states/EmptyState"; import { useDevQuery } from "@/hooks/queries/useDevQuery"; import { useProductTable } from "@/views/products/hooks/useProductTable"; import { createAPIKeyTableColumns } from "./components/APIKeyTableColumns"; -import { CreateApiKeyDialog } from "./components/CreateApiKeyDialog"; +import { CreateApiKeySheet } from "./components/CreateApiKeySheet"; export const ApiKeysPage = () => { const { apiKeys } = useDevQuery(); @@ -56,7 +56,7 @@ export const ApiKeysPage = () => { return (
- diff --git a/vite/src/views/developer/api-keys/components/APIKeyTableColumns.tsx b/vite/src/views/developer/api-keys/components/APIKeyTableColumns.tsx index 89abcf866..436672d10 100644 --- a/vite/src/views/developer/api-keys/components/APIKeyTableColumns.tsx +++ b/vite/src/views/developer/api-keys/components/APIKeyTableColumns.tsx @@ -6,6 +6,7 @@ import { TerminalIcon, UserIcon, } from "lucide-react"; +import { ScopePreview } from "@/components/v2/scope-selector"; import { Tooltip, TooltipContent, @@ -114,6 +115,15 @@ export const createAPIKeyTableColumns = (): ColumnDef[] => [ return
; }, }, + { + header: "Scopes", + accessorKey: "scopes", + size: 200, + enableSorting: false, + cell: ({ row }: { row: Row }) => { + return ; + }, + }, { header: () => (
diff --git a/vite/src/views/developer/api-keys/components/CreateApiKeyDialog.tsx b/vite/src/views/developer/api-keys/components/CreateApiKeyDialog.tsx deleted file mode 100644 index 04d2c0860..000000000 --- a/vite/src/views/developer/api-keys/components/CreateApiKeyDialog.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { Check, Copy } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { z } from "zod/v4"; -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { Input } from "@/components/v2/inputs/Input"; -import { useDevQuery } from "@/hooks/queries/useDevQuery"; -import { DevService } from "@/services/DevService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -const createApiKeySchema = z.object({ - name: z.string().min(1, "Name is required"), -}); - -export const CreateApiKeyDialog = ({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - const { refetch } = useDevQuery(); - const axiosInstance = useAxiosInstance(); - - const [loading, setLoading] = useState(false); - const [name, setName] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [copied, setCopied] = useState(false); - const [copiedEnv, setCopiedEnv] = useState(false); - const [validationError, setValidationError] = useState(null); - - useEffect(() => { - if (open) { - setName(""); - setApiKey(""); - setCopied(false); - setCopiedEnv(false); - setValidationError(null); - } else if (!open) { - refetch(); - setTimeout(() => { - setApiKey(""); - }, 500); - } - }, [open, refetch]); - - useEffect(() => { - const result = createApiKeySchema.safeParse({ name }); - if (!result.success) { - setValidationError(result.error.issues[0]?.message || null); - } else { - setValidationError(null); - } - }, [name]); - - useEffect(() => { - if (copied) { - setTimeout(() => setCopied(false), 1000); - } - }, [copied]); - - useEffect(() => { - if (copiedEnv) { - setTimeout(() => setCopiedEnv(false), 1000); - } - }, [copiedEnv]); - - const handleCreate = async () => { - const result = createApiKeySchema.safeParse({ name }); - if (!result.success) { - setValidationError(result.error.issues[0]?.message || null); - return; - } - - setLoading(true); - try { - const { api_key } = await DevService.createAPIKey(axiosInstance, { - name: name, - }); - - setApiKey(api_key); - } catch (error) { - console.log("Error:", error); - toast.error("Failed to create API key"); - } - - setLoading(false); - }; - - return ( - - - - Create Secret API Key - - {apiKey && ( - - - Please copy your API Key and keep it somewhere safe. You won't - be able to view it anymore after this - - - )} - - - - {apiKey ? ( - -

{apiKey}

- -
- ) : ( - -

Name

- setName(e.target.value)} - variant={validationError ? "destructive" : undefined} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - name.trim() && - !loading && - !validationError - ) { - e.preventDefault(); - handleCreate(); - } - }} - /> - {validationError && ( -

{validationError}

- )} -
- )} -
- - - {apiKey ? ( - - - - ) : ( - - - - )} - - -
-
- ); -}; diff --git a/vite/src/views/developer/api-keys/components/CreateApiKeySheet.tsx b/vite/src/views/developer/api-keys/components/CreateApiKeySheet.tsx new file mode 100644 index 000000000..9b4fee70e --- /dev/null +++ b/vite/src/views/developer/api-keys/components/CreateApiKeySheet.tsx @@ -0,0 +1,266 @@ +import type { ScopeString } from "@autumn/shared"; +import { Check, Copy } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod/v4"; +import { Button } from "@/components/v2/buttons/Button"; +import { Input } from "@/components/v2/inputs/Input"; +import { ScopeSelector } from "@/components/v2/scope-selector"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/v2/sheets/Sheet"; +import { useDevQuery } from "@/hooks/queries/useDevQuery"; +import { useSession } from "@/lib/auth-client"; +import { DevService } from "@/services/DevService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +const createApiKeySchema = z.object({ + name: z.string().min(1, "Name is required"), +}); + +export const CreateApiKeySheet = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) => { + const { refetch } = useDevQuery(); + const axiosInstance = useAxiosInstance(); + const { data: session } = useSession(); + // better-auth's TS inference doesn't auto-propagate customSession + // additions in all setups, so we cast — this mirrors the server-side + // `betterAuthMiddleware` pattern. + const callerScopes = (((session as any)?.scopes ?? []) as string[]); + + const [loading, setLoading] = useState(false); + const [name, setName] = useState(""); + const [scopes, setScopes] = useState([]); + const [apiKey, setApiKey] = useState(""); + const [copied, setCopied] = useState(false); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + if (open) { + setName(""); + setScopes([]); + setApiKey(""); + setCopied(false); + setValidationError(null); + } else if (!open) { + refetch(); + setTimeout(() => { + setApiKey(""); + }, 500); + } + }, [open, refetch]); + + useEffect(() => { + const result = createApiKeySchema.safeParse({ name }); + if (!result.success) { + setValidationError(result.error.issues[0]?.message || null); + } else { + setValidationError(null); + } + }, [name]); + + useEffect(() => { + if (copied) { + setTimeout(() => setCopied(false), 1000); + } + }, [copied]); + + const handleCreate = async () => { + const result = createApiKeySchema.safeParse({ name }); + if (!result.success) { + setValidationError(result.error.issues[0]?.message || null); + return; + } + + setLoading(true); + try { + const { api_key } = await DevService.createAPIKey(axiosInstance, { + name, + scopes, + }); + + setApiKey(api_key); + } catch (error: any) { + console.log("Error:", error); + if (error?.response?.status === 403) { + toast.error("You can't grant scopes you don't have yourself."); + } else { + toast.error( + error?.response?.data?.message ?? "Failed to create API key", + ); + } + } + + setLoading(false); + }; + + return ( + + + + Create Secret API Key + + {apiKey && ( + + Please copy your API Key and keep it somewhere safe. You + won't be able to view it anymore after this + + )} + + + +
+ + {apiKey ? ( + +

{apiKey}

+ +
+ ) : ( + +

Name

+ setName(e.target.value)} + variant={validationError ? "destructive" : undefined} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + name.trim() && + !loading && + !validationError + ) { + e.preventDefault(); + handleCreate(); + } + }} + /> + {validationError && ( +

+ {validationError} +

+ )} + +
+ +
+
+ )} +
+
+ +
+ + {apiKey ? ( + + + + ) : ( + + + + + )} + +
+
+
+ ); +}; diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index aa1df6181..1ce395bd3 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useLocalStorage } from "@/hooks/common/useLocalStorage"; +import { useScopes } from "@/hooks/useScopes"; import { cn } from "@/lib/utils"; import { useEnv } from "@/utils/envUtils"; import { CollapsibleNavGroup } from "./CollapsibleNavGroup"; @@ -86,6 +87,8 @@ export const MainSidebar = ({ const env = useEnv(); const flags = useAutumnFlags(); + const { has } = useScopes(); + const canSeeDev = has("apiKeys:read"); const [storedExpanded, setExpanded] = useLocalStorage( "sidebar.expanded", @@ -183,15 +186,17 @@ export const MainSidebar = ({ title="Analytics" env={env} /> - } - title="Developer" - env={env} - isOpen={devGroupOpen} - onToggle={() => setDevGroupOpen((prev) => !prev)} - subTabs={buildDevSubTabs({ flags })} - /> + {canSeeDev && ( + } + title="Developer" + env={env} + isOpen={devGroupOpen} + onToggle={() => setDevGroupOpen((prev) => !prev)} + subTabs={buildDevSubTabs({ flags })} + /> + )}
diff --git a/vite/src/views/main-sidebar/org-dropdown/manage-org/InvitePopover.tsx b/vite/src/views/main-sidebar/org-dropdown/manage-org/InvitePopover.tsx index 443fd3edf..71f341858 100644 --- a/vite/src/views/main-sidebar/org-dropdown/manage-org/InvitePopover.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/manage-org/InvitePopover.tsx @@ -1,3 +1,4 @@ +import type { Role } from "@autumn/shared"; import { Mail } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -9,14 +10,22 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/v2/buttons/Button"; import { Input } from "@/components/v2/inputs/Input"; +import { RoleSelect } from "@/components/v2/selects/RoleSelect"; import { authClient } from "@/lib/auth-client"; import { getBackendErr } from "@/utils/genUtils"; import { useMemberships } from "../hooks/useMemberships"; const emailSchema = z.email(); +// Owners can invite co-owners (better-auth supports multiple owners on a +// single org). We expose the full role set here and let the server's AC +// gate the action — if a non-owner tries to send `role: "owner"` they'll +// get a 403 back from better-auth's invite endpoint. +const INVITE_ROLES: Role[] = ["owner", "admin", "developer", "sales", "member"]; + export const InvitePopover = () => { const [email, setEmail] = useState(""); + const [role, setRole] = useState("developer"); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const { refetch } = useMemberships(); @@ -31,7 +40,7 @@ export const InvitePopover = () => { setLoading(true); const { error } = await authClient.organization.inviteMember({ email: email, - role: "admin", + role: role, resend: true, }); @@ -43,6 +52,7 @@ export const InvitePopover = () => { await refetch(); toast.success(`Successfully sent invitation to ${email}`); setEmail(""); + setRole("developer"); setOpen(false); } catch (error) { console.error(error); @@ -63,23 +73,81 @@ export const InvitePopover = () => {

Invite by email

+ {/* + Password managers (especially Bitwarden) aggressively autofill + any visible email-looking input with the current user's own + credentials — which is exactly wrong for an invite form. + + We defeat autofill with a layered approach: + + 1. A throwaway hidden dummy input with `type="email"` placed + ABOVE the real field. Password managers typically fill the + first email-shaped field they encounter; sending them + into an `aria-hidden`/tab-index=-1 sink absorbs the + hit and leaves the real field untouched. + + 2. Randomised `name` (per-session) so manager heuristics + can't memoise "fill this orgname's invite box". + + 3. `autoComplete="off"` + Bitwarden/1P/LastPass/Dashlane + opt-out data-attrs. + + 4. `inputMode="email"` keeps the mobile keyboard correct + without the field declaring `type="email"` (which is what + triggers autofill in the first place). + */} + +
setEmail(e.target.value)} + // Multi-vendor autofill opt-out. `data-bwignore` is the + // attribute Bitwarden's content script actually checks + // (not `data-bw-ignore`). `data-form-type="other"` + // opts out of Dashlane. + autoComplete="off" + name={`invitee-${Math.random().toString(36).slice(2, 10)}`} + type="text" + inputMode="email" + spellCheck={false} + data-1p-ignore + data-lpignore="true" + data-bwignore="true" + data-form-type="other" />
+ + ); diff --git a/vite/src/views/main-sidebar/org-dropdown/manage-org/MemberRowToolbar.tsx b/vite/src/views/main-sidebar/org-dropdown/manage-org/MemberRowToolbar.tsx index 907413d10..8b4185125 100644 --- a/vite/src/views/main-sidebar/org-dropdown/manage-org/MemberRowToolbar.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/manage-org/MemberRowToolbar.tsx @@ -1,16 +1,14 @@ import type { Invite, Membership } from "@autumn/shared"; -import { TrashIcon } from "lucide-react"; +import { EllipsisVertical, TrashIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; -import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useOrg } from "@/hooks/common/useOrg"; +} from "@/components/v2/dropdowns/DropdownMenu"; import { authClient } from "@/lib/auth-client"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useMemberships } from "../hooks/useMemberships"; @@ -24,11 +22,10 @@ export const MemberRowToolbar = ({ }) => { const [deleteLoading, setDeleteLoading] = useState(false); const [open, setOpen] = useState(false); - const { org } = useOrg(); const { refetch } = useMemberships(); const axiosInstance = useAxiosInstance(); - const handleDeleteMember = async (e: any) => { + const handleDeleteMember = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -45,15 +42,14 @@ export const MemberRowToolbar = ({ return; } - const response = await axiosInstance.post("/organization/remove-member", { + await axiosInstance.post("/organization/remove-member", { memberId: membership.member.id, userId: membership.user.id, }); - // Refresh the members list await refetch(); toast.success("Member removed successfully"); - setOpen(false); // Close the dropdown + setOpen(false); } catch (error: any) { console.error("Member removal error:", error); if (error.response?.data?.code === "MEMBER_NOT_FOUND") { @@ -66,36 +62,46 @@ export const MemberRowToolbar = ({ } }; - const handleDeleteInvite = async (e: any) => { + const handleDeleteInvite = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setDeleteLoading(true); try { - const { data, error } = await authClient.organization.cancelInvitation({ + const { error } = await authClient.organization.cancelInvitation({ invitationId: invite!.id, }); if (error) { toast.error(error.message); + return; } await refetch(); toast.success("Invite cancelled"); - } catch (error) { + setOpen(false); + } catch { toast.error("Failed to remove invite"); + } finally { + setDeleteLoading(false); } - setDeleteLoading(false); }; return ( - + } + className="!h-5 !w-5 rounded-lg hover:bg-stone-50" + /> - + { if (membership) { handleDeleteMember(e); @@ -104,7 +110,7 @@ export const MemberRowToolbar = ({ } }} > -
+
Remove
diff --git a/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgInvitesList.tsx b/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgInvitesList.tsx index 8a2eed6d2..45b2adc1d 100644 --- a/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgInvitesList.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgInvitesList.tsx @@ -1,7 +1,8 @@ -import type { Invite, Membership } from "@autumn/shared"; +import type { Invite, Membership, Role } from "@autumn/shared"; import { isFuture } from "date-fns"; import { Item, Row } from "@/components/general/TableGrid"; -import { Badge } from "@/components/ui/badge"; +import { Badge } from "@/components/v2/badges/Badge"; +import { ROLE_META } from "@/components/v2/selects/RoleSelect"; import { useSession } from "@/lib/auth-client"; import { formatDateStr } from "@/utils/formatUtils/formatDateUtils"; import { useMemberships } from "../hooks/useMemberships"; @@ -45,12 +46,16 @@ export const OrgInvitesList = () => { {pendingInvites.map((invite: Invite) => { + const roleLabel = + (invite.role && ROLE_META[invite.role as Role]?.label) ?? + invite.role ?? + ""; return ( {invite.email} {invite.status} - {invite.role} + {roleLabel} {formatDateStr(invite.expiresAt)} diff --git a/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgMembersList.tsx b/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgMembersList.tsx index 8f2c48cad..87de54b75 100644 --- a/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgMembersList.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/manage-org/OrgMembersList.tsx @@ -1,39 +1,43 @@ -import type { Membership } from "@autumn/shared"; +import type { Membership, Role } from "@autumn/shared"; import { useState } from "react"; import { toast } from "sonner"; import { Item, Row } from "@/components/general/TableGrid"; -import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; +import { RoleSelect } from "@/components/v2/selects/RoleSelect"; import { authClient, useSession } from "@/lib/auth-client"; import { formatDateStr } from "@/utils/formatUtils/formatDateUtils"; import { useMemberships } from "../hooks/useMemberships"; import { MemberRowToolbar } from "./MemberRowToolbar"; -const ROLE_OPTIONS = ["member", "admin", "owner"] as const; +const NON_OWNER_ROLES: Role[] = ["admin", "developer", "sales", "member"]; +const ALL_ROLES: Role[] = ["owner", ...NON_OWNER_ROLES]; const MemberRoleSelect = ({ membership, + allowOwnerPromotion, + disabled, onRoleChanged, }: { membership: Membership; + /** + * Whether to expose `owner` in the role list. Only true when the + * current user is themselves an owner (owners can promote others to + * co-owners; admins cannot). + */ + allowOwnerPromotion: boolean; + disabled?: boolean; onRoleChanged: () => void; }) => { const [loading, setLoading] = useState(false); + const currentRole = membership.member.role as Role; - const handleRoleChange = async (newRole: string) => { - if (newRole === membership.member.role) return; + const handleRoleChange = async (newRole: Role) => { + if (newRole === currentRole) return; setLoading(true); try { const { error } = await authClient.organization.updateMemberRole({ memberId: membership.member.id, - role: newRole as "member" | "admin" | "owner", + role: newRole, }); if (error) { @@ -50,23 +54,16 @@ const MemberRoleSelect = ({ } }; + const allowed = allowOwnerPromotion ? ALL_ROLES : NON_OWNER_ROLES; + return ( - + ); }; @@ -80,13 +77,13 @@ export const OrgMembersList = () => { if (isMembersLoading) return null; + const currentUserId = data?.session?.userId; const currentMembership = memberships.find( - (membership: Membership) => membership.user.id === data?.session?.userId, + (membership: Membership) => membership.user.id === currentUserId, ); - const isAdmin = - currentMembership?.member.role === "admin" || - currentMembership?.member.role === "owner"; + const currentRole = currentMembership?.member.role as Role | undefined; + const isAdmin = currentRole === "admin" || currentRole === "owner"; return (
@@ -100,23 +97,35 @@ export const OrgMembersList = () => { {memberships.map((membership: Membership) => { const user = membership.user; const member = membership.member; + const memberRole = member.role as Role; + const isSelf = user.id === currentUserId; + const isOwnerUser = currentRole === "owner"; + // Owners can never be demoted (only ownership transfer flows + // change an existing owner's role). Non-admins can't edit + // anyone. Admins can edit non-owner members. Users can always + // demote themselves (unless they are an owner). + const canEdit = + memberRole !== "owner" && (isAdmin || isSelf); + // Owner promotion is only available to other owners, and only + // when editing a non-owner row. + const canPromoteToOwner = + isOwnerUser && memberRole !== "owner" && canEdit; + return ( {user.email} {user.name || "No name"} - {isAdmin ? ( - - ) : ( - {member.role} - )} + {formatDateStr(member.createdAt)} - {isAdmin && member.role !== "owner" && ( + {isAdmin && memberRole !== "owner" && !isSelf && ( )} diff --git a/vite/tsconfig.app.json b/vite/tsconfig.app.json index 477b023ca..bc45b5719 100644 --- a/vite/tsconfig.app.json +++ b/vite/tsconfig.app.json @@ -20,6 +20,7 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "resolveJsonModule": true, /* Linting */ "strict": true,