feat: 🎸 frontend

This commit is contained in:
amianthus
2026-04-24 20:02:37 +01:00
parent 2f6d9b7a0b
commit 423c658be8
17 changed files with 947 additions and 321 deletions

View File

@@ -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 <Badge variant="muted">{emptyLabel}</Badge>;
}
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 <Badge variant="muted">{emptyLabel}</Badge>;
}
return (
<div className="flex flex-wrap gap-1">
{grouped.map((g) => (
<Badge key={g.resource} variant="muted">
{g.resourceName}: {formatActionsCompact(g.actions)}
</Badge>
))}
</div>
);
}

View File

@@ -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.
<div className="flex items-stretch shrink-0 w-[216px]">
{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 = (
<button
type="button"
disabled={optionDisabled}
onClick={() => 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",
)}
>
<span className="text-sm">{option.label}</span>
</button>
);
return (
<ConditionalTooltip
key={option.value}
enabled={!!tooltip}
content={tooltip}
>
{/* Wrap in span so Radix can attach listeners even when the button is disabled. */}
<span className="inline-flex flex-1">{button}</span>
</ConditionalTooltip>
);
})}
</div>
);
}
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 (
<div className="flex flex-col gap-3">
<label className="flex items-start gap-2.5 cursor-pointer select-none">
<Checkbox
checked={restricted}
onCheckedChange={(c) => handleToggleRestricted(c === true)}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<span className="text-sm text-foreground">Restricted mode</span>
<span className="text-xs text-muted-foreground">
Limit this key to specific scopes. Leave unchecked for full
access.
</span>
</div>
</label>
{restricted && (
<div className="flex flex-col border-t border-border">
{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 (
<div
key={resource}
className={cn(
"flex items-center justify-between gap-4 py-3 border-b border-border",
fullyUnavailable && "opacity-50",
)}
>
<ConditionalTooltip
enabled={!!meta.description}
content={meta.description}
>
<span className="text-sm text-foreground cursor-help">
{meta.namePlural}
</span>
</ConditionalTooltip>
<TriStatePicker
options={TRI_OPTIONS_FULL}
value={triValue}
onChange={(next) =>
onChange(applyTriState(value, resource, next))
}
readEnabled={readAvailable}
writeEnabled={writeAvailable}
writeUnavailableReason={writeReason}
disabled={disabled}
/>
</div>
);
})}
<div className="pt-3 text-xs text-muted-foreground">
Granting:{" "}
<span className="text-foreground">
{summary.scopeCount} scope
{summary.scopeCount === 1 ? "" : "s"}
</span>{" "}
across{" "}
<span className="text-foreground">
{summary.resourceCount} resource
{summary.resourceCount === 1 ? "" : "s"}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./ScopePreview";
export * from "./ScopeSelector";

View File

@@ -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<Role, { label: string; description: string }> = {
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 (
<Select
value={value}
onValueChange={(v) => onChange(v as Role)}
disabled={disabled}
>
<SelectTrigger className={className}>
<SelectValue placeholder={placeholder ?? "Select a role"}>
{selectedLabel}
</SelectValue>
</SelectTrigger>
{/*
`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.
*/}
<SelectContent className="max-w-[340px]">
{allowed.map((role) => (
<SelectItem
key={role}
value={role}
textValue={ROLE_META[role].label}
className="items-start"
>
<div className="flex flex-col items-start py-0.5 gap-0.5 w-full">
<span className="text-sm font-medium">
{ROLE_META[role].label}
</span>
<span className="text-xs text-muted-foreground whitespace-normal break-words leading-snug">
{ROLE_META[role].description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 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 };

View File

@@ -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]);
}

View File

@@ -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(),
],

View File

@@ -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;
}

View File

@@ -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 (
<div className="h-fit max-h-full">
<CreateApiKeyDialog
<CreateApiKeySheet
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>

View File

@@ -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<ApiKey, unknown>[] => [
return <div className="text-t4"></div>;
},
},
{
header: "Scopes",
accessorKey: "scopes",
size: 200,
enableSorting: false,
cell: ({ row }: { row: Row<ApiKey> }) => {
return <ScopePreview scopes={row.original.scopes ?? null} />;
},
},
{
header: () => (
<div className="flex items-center gap-1.5">

View File

@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="transition-all duration-300"
style={{
maxWidth: apiKey ? "32rem" : "28rem",
transition:
"max-width 350ms linear(0, 0.3566, 0.7963, 1.0045, 1.0459, 1.0287, 1.0088, 0.9996, 1, 0.9987, 0.9996, 1)",
}}
>
<DialogHeader>
<DialogTitle>Create Secret API Key</DialogTitle>
<AnimatePresence mode="wait">
{apiKey && (
<motion.div
key="description"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
>
<DialogDescription>
Please copy your API Key and keep it somewhere safe. You won't
be able to view it anymore after this
</DialogDescription>
</motion.div>
)}
</AnimatePresence>
</DialogHeader>
<AnimatePresence mode="wait" initial={false}>
{apiKey ? (
<motion.div
key="api-key"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
className="flex justify-between bg-input/50 dark:bg-input/30 p-2 px-3 text-t2 rounded-md items-center"
>
<p className="text-sm">{apiKey}</p>
<button
type="button"
className="text-t2 hover:text-t2/80 cursor-pointer"
onClick={() => {
setCopied(true);
navigator.clipboard.writeText(apiKey);
}}
>
{copied ? <Check size={15} /> : <Copy size={15} />}
</button>
</motion.div>
) : (
<motion.div
key="name-input"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
>
<p className="mb-2 text-sm text-t3">Name</p>
<Input
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
variant={validationError ? "destructive" : undefined}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
name.trim() &&
!loading &&
!validationError
) {
e.preventDefault();
handleCreate();
}
}}
/>
{validationError && (
<p className="mt-2 text-sm text-red-500">{validationError}</p>
)}
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<AnimatePresence mode="wait" initial={false}>
{apiKey ? (
<motion.div
key="close-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.2,
}}
>
<Button
onClick={() => onOpenChange(false)}
className="cursor-pointer"
>
Close
</Button>
</motion.div>
) : (
<motion.div
key="create-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.2,
}}
>
<Button
isLoading={loading}
onClick={handleCreate}
variant="primary"
className="cursor-pointer"
disabled={!!validationError || !name.trim()}
>
Create
</Button>
</motion.div>
)}
</AnimatePresence>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -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<ScopeString[]>([]);
const [apiKey, setApiKey] = useState("");
const [copied, setCopied] = useState(false);
const [validationError, setValidationError] = useState<string | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="sm:max-w-2xl md:max-w-2xl">
<SheetHeader>
<SheetTitle>Create Secret API Key</SheetTitle>
<AnimatePresence mode="wait">
{apiKey && (
<motion.p
key="description"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
className="text-muted-foreground text-sm"
>
Please copy your API Key and keep it somewhere safe. You
won't be able to view it anymore after this
</motion.p>
)}
</AnimatePresence>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<AnimatePresence mode="wait" initial={false}>
{apiKey ? (
<motion.div
key="api-key"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
className="flex justify-between bg-input/50 dark:bg-input/30 p-2 px-3 text-t2 rounded-md items-center"
>
<p className="text-sm">{apiKey}</p>
<button
type="button"
className="text-t2 hover:text-t2/80 cursor-pointer"
onClick={() => {
setCopied(true);
navigator.clipboard.writeText(apiKey);
}}
>
{copied ? <Check size={15} /> : <Copy size={15} />}
</button>
</motion.div>
) : (
<motion.div
key="name-input"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
>
<p className="mb-2 text-sm text-t3">Name</p>
<Input
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
variant={validationError ? "destructive" : undefined}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
name.trim() &&
!loading &&
!validationError
) {
e.preventDefault();
handleCreate();
}
}}
/>
{validationError && (
<p className="mt-2 text-sm text-red-500">
{validationError}
</p>
)}
<div className="mt-6">
<ScopeSelector
value={scopes}
onChange={setScopes}
availableScopes={callerScopes}
disabled={loading}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex justify-end gap-2 border-t border-border/40 p-4">
<AnimatePresence mode="wait" initial={false}>
{apiKey ? (
<motion.div
key="close-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.2,
}}
>
<Button
onClick={() => onOpenChange(false)}
className="cursor-pointer"
>
Close
</Button>
</motion.div>
) : (
<motion.div
key="create-button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.2,
}}
className="flex gap-2"
>
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
className="cursor-pointer"
disabled={loading}
>
Cancel
</Button>
<Button
isLoading={loading}
onClick={handleCreate}
variant="primary"
className="cursor-pointer"
disabled={!!validationError || !name.trim()}
>
Create key
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</SheetContent>
</Sheet>
);
};

View File

@@ -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<boolean>(
"sidebar.expanded",
@@ -183,15 +186,17 @@ export const MainSidebar = ({
title="Analytics"
env={env}
/>
<CollapsibleNavGroup
value="dev"
icon={<TerminalWindowIcon size={16} weight="fill" />}
title="Developer"
env={env}
isOpen={devGroupOpen}
onToggle={() => setDevGroupOpen((prev) => !prev)}
subTabs={buildDevSubTabs({ flags })}
/>
{canSeeDev && (
<CollapsibleNavGroup
value="dev"
icon={<TerminalWindowIcon size={16} weight="fill" />}
title="Developer"
env={env}
isOpen={devGroupOpen}
onToggle={() => setDevGroupOpen((prev) => !prev)}
subTabs={buildDevSubTabs({ flags })}
/>
)}
</div>
</div>

View File

@@ -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<Role>("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 = () => {
<p className="text-t3 text-sm">Invite by email</p>
</div>
{/*
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).
*/}
<input
type="email"
name="dummy-email-sink"
tabIndex={-1}
aria-hidden="true"
style={{
position: "absolute",
top: 0,
left: 0,
opacity: 0,
height: 0,
width: 0,
pointerEvents: "none",
}}
/>
<div className="flex items-center gap-2">
<Input
className="h-7"
placeholder="Email"
value={email}
onChange={(e) => 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"
/>
<Button
variant="primary"
className="h-6.5! mt-0!"
// endIcon={<Plus size={10} />}
onClick={handleInvite}
isLoading={loading}
>
Send
</Button>
</div>
<RoleSelect
value={role}
onChange={setRole}
allowed={INVITE_ROLES}
className="h-7 w-full"
/>
</PopoverContent>
</Popover>
);

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<ToolbarButton />
<IconButton
variant="skeleton"
size="icon"
iconOrientation="center"
icon={<EllipsisVertical />}
className="!h-5 !w-5 rounded-lg hover:bg-stone-50"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
shimmer={deleteLoading}
className="flex justify-between text-t2"
className="flex justify-between"
onClick={(e) => {
if (membership) {
handleDeleteMember(e);
@@ -104,7 +110,7 @@ export const MemberRowToolbar = ({
}
}}
>
<div className="flex justify-between items-center w-full">
<div className="flex justify-between items-center w-full gap-4">
<span>Remove</span>
<TrashIcon size={12} />
</div>

View File

@@ -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 = () => {
<Item className="flex-1"></Item>
</Row>
{pendingInvites.map((invite: Invite) => {
const roleLabel =
(invite.role && ROLE_META[invite.role as Role]?.label) ??
invite.role ??
"";
return (
<Row key={invite.id} className="flex px-6 text-sm text-t2">
<Item className="flex-6">{invite.email}</Item>
<Item className="flex-5">{invite.status}</Item>
<Item className="flex-3">
<Badge variant="outline">{invite.role}</Badge>
<Badge variant="muted">{roleLabel}</Badge>
</Item>
<Item className="flex-3">{formatDateStr(invite.expiresAt)}</Item>
<Item className="flex-1 flex justify-end">

View File

@@ -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 (
<Select
value={membership.member.role}
onValueChange={handleRoleChange}
disabled={loading}
>
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((role) => (
<SelectItem key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<RoleSelect
value={currentRole}
onChange={handleRoleChange}
allowed={allowed}
disabled={disabled || loading}
className="h-7 w-[140px] text-xs"
/>
);
};
@@ -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 (
<div className="h-full overflow-y-auto">
@@ -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 (
<Row key={membership.user.id} className="flex px-6 text-sm text-t2">
<Item className="flex-[6]">{user.email}</Item>
<Item className="flex-[5] text-t3">{user.name || "No name"}</Item>
<Item className="flex-[3]">
{isAdmin ? (
<MemberRoleSelect
membership={membership}
onRoleChanged={refetch}
/>
) : (
<Badge variant="outline">{member.role}</Badge>
)}
<MemberRoleSelect
membership={membership}
allowOwnerPromotion={canPromoteToOwner}
disabled={!canEdit}
onRoleChanged={refetch}
/>
</Item>
<Item className="flex-[3]">{formatDateStr(member.createdAt)}</Item>
<Item className="flex-[1] flex justify-end">
{isAdmin && member.role !== "owner" && (
{isAdmin && memberRole !== "owner" && !isSelf && (
<MemberRowToolbar membership={membership} />
)}
</Item>

View File

@@ -20,6 +20,7 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
/* Linting */
"strict": true,