feat: 🎸 frontend
This commit is contained in:
54
vite/src/components/v2/scope-selector/ScopePreview.tsx
Normal file
54
vite/src/components/v2/scope-selector/ScopePreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
vite/src/components/v2/scope-selector/ScopeSelector.tsx
Normal file
290
vite/src/components/v2/scope-selector/ScopeSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
vite/src/components/v2/scope-selector/index.ts
Normal file
2
vite/src/components/v2/scope-selector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./ScopePreview";
|
||||||
|
export * from "./ScopeSelector";
|
||||||
129
vite/src/components/v2/selects/RoleSelect.tsx
Normal file
129
vite/src/components/v2/selects/RoleSelect.tsx
Normal 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 };
|
||||||
20
vite/src/hooks/useScopes.ts
Normal file
20
vite/src/hooks/useScopes.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ac, roles } from "@autumn/shared";
|
||||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
@@ -10,7 +11,7 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: import.meta.env.VITE_BACKEND_URL,
|
baseURL: import.meta.env.VITE_BACKEND_URL,
|
||||||
plugins: [
|
plugins: [
|
||||||
emailOTPClient(),
|
emailOTPClient(),
|
||||||
organizationClient(),
|
organizationClient({ ac, roles }),
|
||||||
adminClient(),
|
adminClient(),
|
||||||
oauthProviderClient(),
|
oauthProviderClient(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
|
|
||||||
export class DevService {
|
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);
|
const { data: resBody } = await axiosInstance.post("/dev/api_key", data);
|
||||||
return resBody;
|
return resBody;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EmptyState } from "@/components/v2/empty-states/EmptyState";
|
|||||||
import { useDevQuery } from "@/hooks/queries/useDevQuery";
|
import { useDevQuery } from "@/hooks/queries/useDevQuery";
|
||||||
import { useProductTable } from "@/views/products/hooks/useProductTable";
|
import { useProductTable } from "@/views/products/hooks/useProductTable";
|
||||||
import { createAPIKeyTableColumns } from "./components/APIKeyTableColumns";
|
import { createAPIKeyTableColumns } from "./components/APIKeyTableColumns";
|
||||||
import { CreateApiKeyDialog } from "./components/CreateApiKeyDialog";
|
import { CreateApiKeySheet } from "./components/CreateApiKeySheet";
|
||||||
|
|
||||||
export const ApiKeysPage = () => {
|
export const ApiKeysPage = () => {
|
||||||
const { apiKeys } = useDevQuery();
|
const { apiKeys } = useDevQuery();
|
||||||
@@ -56,7 +56,7 @@ export const ApiKeysPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-fit max-h-full">
|
<div className="h-fit max-h-full">
|
||||||
<CreateApiKeyDialog
|
<CreateApiKeySheet
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ScopePreview } from "@/components/v2/scope-selector";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -114,6 +115,15 @@ export const createAPIKeyTableColumns = (): ColumnDef<ApiKey, unknown>[] => [
|
|||||||
return <div className="text-t4">—</div>;
|
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: () => (
|
header: () => (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons";
|
import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons";
|
||||||
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
||||||
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
||||||
|
import { useScopes } from "@/hooks/useScopes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEnv } from "@/utils/envUtils";
|
import { useEnv } from "@/utils/envUtils";
|
||||||
import { CollapsibleNavGroup } from "./CollapsibleNavGroup";
|
import { CollapsibleNavGroup } from "./CollapsibleNavGroup";
|
||||||
@@ -86,6 +87,8 @@ export const MainSidebar = ({
|
|||||||
const env = useEnv();
|
const env = useEnv();
|
||||||
|
|
||||||
const flags = useAutumnFlags();
|
const flags = useAutumnFlags();
|
||||||
|
const { has } = useScopes();
|
||||||
|
const canSeeDev = has("apiKeys:read");
|
||||||
|
|
||||||
const [storedExpanded, setExpanded] = useLocalStorage<boolean>(
|
const [storedExpanded, setExpanded] = useLocalStorage<boolean>(
|
||||||
"sidebar.expanded",
|
"sidebar.expanded",
|
||||||
@@ -183,6 +186,7 @@ export const MainSidebar = ({
|
|||||||
title="Analytics"
|
title="Analytics"
|
||||||
env={env}
|
env={env}
|
||||||
/>
|
/>
|
||||||
|
{canSeeDev && (
|
||||||
<CollapsibleNavGroup
|
<CollapsibleNavGroup
|
||||||
value="dev"
|
value="dev"
|
||||||
icon={<TerminalWindowIcon size={16} weight="fill" />}
|
icon={<TerminalWindowIcon size={16} weight="fill" />}
|
||||||
@@ -192,6 +196,7 @@ export const MainSidebar = ({
|
|||||||
onToggle={() => setDevGroupOpen((prev) => !prev)}
|
onToggle={() => setDevGroupOpen((prev) => !prev)}
|
||||||
subTabs={buildDevSubTabs({ flags })}
|
subTabs={buildDevSubTabs({ flags })}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Role } from "@autumn/shared";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -9,14 +10,22 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/v2/buttons/Button";
|
import { Button } from "@/components/v2/buttons/Button";
|
||||||
import { Input } from "@/components/v2/inputs/Input";
|
import { Input } from "@/components/v2/inputs/Input";
|
||||||
|
import { RoleSelect } from "@/components/v2/selects/RoleSelect";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getBackendErr } from "@/utils/genUtils";
|
import { getBackendErr } from "@/utils/genUtils";
|
||||||
import { useMemberships } from "../hooks/useMemberships";
|
import { useMemberships } from "../hooks/useMemberships";
|
||||||
|
|
||||||
const emailSchema = z.email();
|
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 = () => {
|
export const InvitePopover = () => {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("developer");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { refetch } = useMemberships();
|
const { refetch } = useMemberships();
|
||||||
@@ -31,7 +40,7 @@ export const InvitePopover = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { error } = await authClient.organization.inviteMember({
|
const { error } = await authClient.organization.inviteMember({
|
||||||
email: email,
|
email: email,
|
||||||
role: "admin",
|
role: role,
|
||||||
resend: true,
|
resend: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +52,7 @@ export const InvitePopover = () => {
|
|||||||
await refetch();
|
await refetch();
|
||||||
toast.success(`Successfully sent invitation to ${email}`);
|
toast.success(`Successfully sent invitation to ${email}`);
|
||||||
setEmail("");
|
setEmail("");
|
||||||
|
setRole("developer");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -63,23 +73,81 @@ export const InvitePopover = () => {
|
|||||||
<p className="text-t3 text-sm">Invite by email</p>
|
<p className="text-t3 text-sm">Invite by email</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
className="h-7"
|
className="h-7"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="h-6.5! mt-0!"
|
className="h-6.5! mt-0!"
|
||||||
// endIcon={<Plus size={10} />}
|
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RoleSelect
|
||||||
|
value={role}
|
||||||
|
onChange={setRole}
|
||||||
|
allowed={INVITE_ROLES}
|
||||||
|
className="h-7 w-full"
|
||||||
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import type { Invite, Membership } from "@autumn/shared";
|
import type { Invite, Membership } from "@autumn/shared";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { EllipsisVertical, TrashIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
|
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||||
import { useOrg } from "@/hooks/common/useOrg";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
import { useMemberships } from "../hooks/useMemberships";
|
import { useMemberships } from "../hooks/useMemberships";
|
||||||
@@ -24,11 +22,10 @@ export const MemberRowToolbar = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { org } = useOrg();
|
|
||||||
const { refetch } = useMemberships();
|
const { refetch } = useMemberships();
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
|
|
||||||
const handleDeleteMember = async (e: any) => {
|
const handleDeleteMember = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@@ -45,15 +42,14 @@ export const MemberRowToolbar = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axiosInstance.post("/organization/remove-member", {
|
await axiosInstance.post("/organization/remove-member", {
|
||||||
memberId: membership.member.id,
|
memberId: membership.member.id,
|
||||||
userId: membership.user.id,
|
userId: membership.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh the members list
|
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Member removed successfully");
|
toast.success("Member removed successfully");
|
||||||
setOpen(false); // Close the dropdown
|
setOpen(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Member removal error:", error);
|
console.error("Member removal error:", error);
|
||||||
if (error.response?.data?.code === "MEMBER_NOT_FOUND") {
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await authClient.organization.cancelInvitation({
|
const { error } = await authClient.organization.cancelInvitation({
|
||||||
invitationId: invite!.id,
|
invitationId: invite!.id,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Invite cancelled");
|
toast.success("Invite cancelled");
|
||||||
} catch (error) {
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
toast.error("Failed to remove invite");
|
toast.error("Failed to remove invite");
|
||||||
}
|
} finally {
|
||||||
setDeleteLoading(false);
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ToolbarButton />
|
<IconButton
|
||||||
|
variant="skeleton"
|
||||||
|
size="icon"
|
||||||
|
iconOrientation="center"
|
||||||
|
icon={<EllipsisVertical />}
|
||||||
|
className="!h-5 !w-5 rounded-lg hover:bg-stone-50"
|
||||||
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
shimmer={deleteLoading}
|
shimmer={deleteLoading}
|
||||||
className="flex justify-between text-t2"
|
className="flex justify-between"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (membership) {
|
if (membership) {
|
||||||
handleDeleteMember(e);
|
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>
|
<span>Remove</span>
|
||||||
<TrashIcon size={12} />
|
<TrashIcon size={12} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { isFuture } from "date-fns";
|
||||||
import { Item, Row } from "@/components/general/TableGrid";
|
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 { useSession } from "@/lib/auth-client";
|
||||||
import { formatDateStr } from "@/utils/formatUtils/formatDateUtils";
|
import { formatDateStr } from "@/utils/formatUtils/formatDateUtils";
|
||||||
import { useMemberships } from "../hooks/useMemberships";
|
import { useMemberships } from "../hooks/useMemberships";
|
||||||
@@ -45,12 +46,16 @@ export const OrgInvitesList = () => {
|
|||||||
<Item className="flex-1"></Item>
|
<Item className="flex-1"></Item>
|
||||||
</Row>
|
</Row>
|
||||||
{pendingInvites.map((invite: Invite) => {
|
{pendingInvites.map((invite: Invite) => {
|
||||||
|
const roleLabel =
|
||||||
|
(invite.role && ROLE_META[invite.role as Role]?.label) ??
|
||||||
|
invite.role ??
|
||||||
|
"";
|
||||||
return (
|
return (
|
||||||
<Row key={invite.id} className="flex px-6 text-sm text-t2">
|
<Row key={invite.id} className="flex px-6 text-sm text-t2">
|
||||||
<Item className="flex-6">{invite.email}</Item>
|
<Item className="flex-6">{invite.email}</Item>
|
||||||
<Item className="flex-5">{invite.status}</Item>
|
<Item className="flex-5">{invite.status}</Item>
|
||||||
<Item className="flex-3">
|
<Item className="flex-3">
|
||||||
<Badge variant="outline">{invite.role}</Badge>
|
<Badge variant="muted">{roleLabel}</Badge>
|
||||||
</Item>
|
</Item>
|
||||||
<Item className="flex-3">{formatDateStr(invite.expiresAt)}</Item>
|
<Item className="flex-3">{formatDateStr(invite.expiresAt)}</Item>
|
||||||
<Item className="flex-1 flex justify-end">
|
<Item className="flex-1 flex justify-end">
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import type { Membership } from "@autumn/shared";
|
import type { Membership, Role } from "@autumn/shared";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Item, Row } from "@/components/general/TableGrid";
|
import { Item, Row } from "@/components/general/TableGrid";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { RoleSelect } from "@/components/v2/selects/RoleSelect";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/v2/selects/Select";
|
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
import { formatDateStr } from "@/utils/formatUtils/formatDateUtils";
|
import { formatDateStr } from "@/utils/formatUtils/formatDateUtils";
|
||||||
import { useMemberships } from "../hooks/useMemberships";
|
import { useMemberships } from "../hooks/useMemberships";
|
||||||
import { MemberRowToolbar } from "./MemberRowToolbar";
|
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 = ({
|
const MemberRoleSelect = ({
|
||||||
membership,
|
membership,
|
||||||
|
allowOwnerPromotion,
|
||||||
|
disabled,
|
||||||
onRoleChanged,
|
onRoleChanged,
|
||||||
}: {
|
}: {
|
||||||
membership: Membership;
|
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;
|
onRoleChanged: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const currentRole = membership.member.role as Role;
|
||||||
|
|
||||||
const handleRoleChange = async (newRole: string) => {
|
const handleRoleChange = async (newRole: Role) => {
|
||||||
if (newRole === membership.member.role) return;
|
if (newRole === currentRole) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { error } = await authClient.organization.updateMemberRole({
|
const { error } = await authClient.organization.updateMemberRole({
|
||||||
memberId: membership.member.id,
|
memberId: membership.member.id,
|
||||||
role: newRole as "member" | "admin" | "owner",
|
role: newRole,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -50,23 +54,16 @@ const MemberRoleSelect = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowed = allowOwnerPromotion ? ALL_ROLES : NON_OWNER_ROLES;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<RoleSelect
|
||||||
value={membership.member.role}
|
value={currentRole}
|
||||||
onValueChange={handleRoleChange}
|
onChange={handleRoleChange}
|
||||||
disabled={loading}
|
allowed={allowed}
|
||||||
>
|
disabled={disabled || loading}
|
||||||
<SelectTrigger className="h-7 w-[100px] text-xs">
|
className="h-7 w-[140px] text-xs"
|
||||||
<SelectValue />
|
/>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROLE_OPTIONS.map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,13 +77,13 @@ export const OrgMembersList = () => {
|
|||||||
|
|
||||||
if (isMembersLoading) return null;
|
if (isMembersLoading) return null;
|
||||||
|
|
||||||
|
const currentUserId = data?.session?.userId;
|
||||||
const currentMembership = memberships.find(
|
const currentMembership = memberships.find(
|
||||||
(membership: Membership) => membership.user.id === data?.session?.userId,
|
(membership: Membership) => membership.user.id === currentUserId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAdmin =
|
const currentRole = currentMembership?.member.role as Role | undefined;
|
||||||
currentMembership?.member.role === "admin" ||
|
const isAdmin = currentRole === "admin" || currentRole === "owner";
|
||||||
currentMembership?.member.role === "owner";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
@@ -100,23 +97,35 @@ export const OrgMembersList = () => {
|
|||||||
{memberships.map((membership: Membership) => {
|
{memberships.map((membership: Membership) => {
|
||||||
const user = membership.user;
|
const user = membership.user;
|
||||||
const member = membership.member;
|
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 (
|
return (
|
||||||
<Row key={membership.user.id} className="flex px-6 text-sm text-t2">
|
<Row key={membership.user.id} className="flex px-6 text-sm text-t2">
|
||||||
<Item className="flex-[6]">{user.email}</Item>
|
<Item className="flex-[6]">{user.email}</Item>
|
||||||
<Item className="flex-[5] text-t3">{user.name || "No name"}</Item>
|
<Item className="flex-[5] text-t3">{user.name || "No name"}</Item>
|
||||||
<Item className="flex-[3]">
|
<Item className="flex-[3]">
|
||||||
{isAdmin ? (
|
|
||||||
<MemberRoleSelect
|
<MemberRoleSelect
|
||||||
membership={membership}
|
membership={membership}
|
||||||
|
allowOwnerPromotion={canPromoteToOwner}
|
||||||
|
disabled={!canEdit}
|
||||||
onRoleChanged={refetch}
|
onRoleChanged={refetch}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Badge variant="outline">{member.role}</Badge>
|
|
||||||
)}
|
|
||||||
</Item>
|
</Item>
|
||||||
<Item className="flex-[3]">{formatDateStr(member.createdAt)}</Item>
|
<Item className="flex-[3]">{formatDateStr(member.createdAt)}</Item>
|
||||||
<Item className="flex-[1] flex justify-end">
|
<Item className="flex-[1] flex justify-end">
|
||||||
{isAdmin && member.role !== "owner" && (
|
{isAdmin && memberRole !== "owner" && !isSelf && (
|
||||||
<MemberRowToolbar membership={membership} />
|
<MemberRowToolbar membership={membership} />
|
||||||
)}
|
)}
|
||||||
</Item>
|
</Item>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user