Files
cfw-autumn/vite/src/components/v2/scope-selector/ScopeSelector.tsx
amianthus 423c658be8 feat: 🎸 frontend
2026-04-24 20:02:37 +01:00

291 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}