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 = (
+ onChange(option.value)}
+ className={cn(
+ "flex-1 flex items-center justify-center gap-1 px-[6px] py-1 h-6 text-body border transition-none outline-none whitespace-nowrap !bg-interactive-secondary cursor-pointer",
+ "hover:text-primary focus-visible:text-primary",
+ "disabled:opacity-50 disabled:cursor-not-allowed",
+ isActive &&
+ " text-primary shadow-[0px_3px_4px_0px_inset_rgba(0,0,0,0.04)]",
+ !isActive &&
+ "bg-interative-secondary shadow-[0px_-3px_4px_0px_inset_rgba(0,0,0,0.04)]",
+ isFirst && "rounded-l-lg border-l",
+ !isFirst && "border-l-0",
+ isLast && "rounded-r-lg",
+ )}
+ >
+ {option.label}
+
+ );
+
+ 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 (
+
+
+ handleToggleRestricted(c === true)}
+ disabled={disabled}
+ className="mt-0.5"
+ />
+
+ Restricted mode
+
+ Limit this key to specific scopes. Leave unchecked for full
+ access.
+
+
+
+
+ {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 (
+ onChange(v as Role)}
+ disabled={disabled}
+ >
+
+
+ {selectedLabel}
+
+
+ {/*
+ `max-w-[340px]` constrains the dropdown so long descriptions
+ have an edge to wrap against. `whitespace-normal` + `break-words`
+ on the description overrides any inherited `whitespace-nowrap`
+ from the Radix select item styling.
+ */}
+
+ {allowed.map((role) => (
+
+
+
+ {ROLE_META[role].label}
+
+
+ {ROLE_META[role].description}
+
+
+
+ ))}
+
+
+ );
+}
+
+// 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}
- {
- setCopied(true);
- navigator.clipboard.writeText(apiKey);
- }}
- >
- {copied ? : }
-
-
- ) : (
-
- 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 ? (
-
- onOpenChange(false)}
- className="cursor-pointer"
- >
- Close
-
-
- ) : (
-
-
- Create
-
-
- )}
-
-
-
-
- );
-};
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}
+ {
+ setCopied(true);
+ navigator.clipboard.writeText(apiKey);
+ }}
+ >
+ {copied ? : }
+
+
+ ) : (
+
+ 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 ? (
+
+ onOpenChange(false)}
+ className="cursor-pointer"
+ >
+ Close
+
+
+ ) : (
+
+ onOpenChange(false)}
+ className="cursor-pointer"
+ disabled={loading}
+ >
+ Cancel
+
+
+ Create key
+
+
+ )}
+
+
+
+
+ );
+};
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"
/>
}
onClick={handleInvite}
isLoading={loading}
>
Send
+
+
);
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 (
-
-
-
-
-
- {ROLE_OPTIONS.map((role) => (
-
- {role.charAt(0).toUpperCase() + role.slice(1)}
-
- ))}
-
-
+
);
};
@@ -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,