mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
Merge remote-tracking branch 'origin/master' into feat/env-var
This commit is contained in:
commit
e7adbd9968
323 files changed
+14639
-3988
No files matched your search
@@ -139,9 +139,12 @@ jobs:
|
||||
# Poll the "Vercel – docs" commit status instead — Vercel keeps posting
|
||||
# those — then resolve the deployment it points to via Vercel's own API
|
||||
# to get the actual preview URL. See scripts/waitForVercelDocsPreview.js.
|
||||
# A Vercel failure or timeout is not the author's problem, and the required
|
||||
# "Vercel – docs" check already reports it. Resolve no URL and skip below.
|
||||
- name: Wait for Vercel docs preview
|
||||
if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.docs_app == 'true'
|
||||
id: deployment
|
||||
continue-on-error: true
|
||||
run: node scripts/waitForVercelDocsPreview.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -156,30 +159,61 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_URL_INPUT: ${{ inputs.base_url }}
|
||||
DEPLOYMENT_URL: ${{ steps.deployment.outputs.deployment-url }}
|
||||
DOCS_APP_CHANGED: ${{ steps.changes.outputs.docs_app }}
|
||||
PAGE_PATHS: ${{ steps.scope.outputs.paths }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
printf 'url=%s\n' "$BASE_URL_INPUT" >> "$GITHUB_OUTPUT"
|
||||
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$DOCS_APP_CHANGED" = "true" ] && [ -n "$DEPLOYMENT_URL" ]; then
|
||||
printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "use_bypass=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Harness-only PRs have no docs preview; test against production.
|
||||
echo "url=https://supabase.com" >> "$GITHUB_OUTPUT"
|
||||
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
|
||||
# Non-production targets are previews, which may need the bypass.
|
||||
if [ "$BASE_URL_INPUT" = "https://supabase.com" ]; then
|
||||
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "use_bypass=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "should_test=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$DEPLOYMENT_URL" ]; then
|
||||
printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "use_bypass=true" >> "$GITHUB_OUTPUT"
|
||||
echo "should_test=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Production is not a substitute: it lacks pages this pull request
|
||||
# adds, so testing it fails a required check for a valid change.
|
||||
echo "url=" >> "$GITHUB_OUTPUT"
|
||||
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
|
||||
echo "should_test=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::No Vercel docs preview URL for this pull request, so there is nothing serving its content to test. Skipping Playwright rather than testing production, which does not have pages this pull request adds."
|
||||
{
|
||||
echo "### Docs E2E skipped: no preview to test against"
|
||||
echo
|
||||
echo "Nothing is serving this pull request's content, and production is not a"
|
||||
echo "substitute — pages it adds do not exist there yet."
|
||||
echo
|
||||
echo "Fork pull requests reach this path because they run without repository"
|
||||
echo "secrets. A maintainer can run the suite against the preview manually:"
|
||||
echo
|
||||
echo '```'
|
||||
echo "gh workflow run docs-e2e.yml \\"
|
||||
echo " -f base_url=<preview-url> \\"
|
||||
echo " -f page_paths=$PAGE_PATHS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.scope.outputs.skip == 'false'
|
||||
if: steps.base-url.outputs.should_test == 'true'
|
||||
run: pnpm install --frozen-lockfile --filter=e2e-docs...
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.scope.outputs.skip == 'false'
|
||||
if: steps.base-url.outputs.should_test == 'true'
|
||||
run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell
|
||||
|
||||
- name: Run docs E2E
|
||||
if: steps.scope.outputs.skip == 'false'
|
||||
if: steps.base-url.outputs.should_test == 'true'
|
||||
working-directory: e2e/docs
|
||||
run: pnpm run e2e:docs
|
||||
env:
|
||||
|
||||
@@ -35,6 +35,26 @@ This creates Markdown files for all routes under the `public/markdown/guides` di
|
||||
|
||||
For production this setup runs as a `prebuild` task to allow Vercel to bundle these files with middleware and functions.
|
||||
|
||||
## Accessibility checks
|
||||
|
||||
Docs pages are scanned for WCAG 2.1 A/AA issues with axe-core, as part of the
|
||||
Playwright suite in `e2e/docs`. Pull requests scan the pages your change affects,
|
||||
limited to the main article.
|
||||
|
||||
To scan the pages your current branch changes:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y
|
||||
```
|
||||
|
||||
That resolves which pages to scan from your branch, but reads them from
|
||||
production, so it won't see your edits and will 404 on a page you just added.
|
||||
Point `PLAYWRIGHT_BASE_URL` at your pull request's preview to scan your own
|
||||
content.
|
||||
|
||||
See [`e2e/docs/README.md`](https://github.com/supabase/supabase/blob/master/e2e/docs/README.md)
|
||||
for coverage and skipped rules.
|
||||
|
||||
## Contributing
|
||||
|
||||
For repo organization and style guide, see the [contributing guide](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md).
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Badge } from 'ui'
|
||||
import { GlassPanel } from 'ui-patterns/GlassPanel'
|
||||
import { Heading } from 'ui/src/components/CustomHTMLElements'
|
||||
@@ -66,7 +67,11 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) {
|
||||
<section className="space-y-4">
|
||||
<ContentListingGroupHeading group={group} />
|
||||
<div className="not-prose space-y-4">
|
||||
{group.description && <p className="text-foreground-light">{group.description}</p>}
|
||||
{group.description && (
|
||||
<div className="text-foreground-light [&_a]:text-foreground [&_a]:underline [&_p]:m-0">
|
||||
<ReactMarkdown>{group.description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<ul className={listClassName}>
|
||||
{items.map((item) => {
|
||||
const external = isExternalContentListingHref(item.href)
|
||||
|
||||
@@ -3062,7 +3062,7 @@ export const telemetry: NavMenuConstant = {
|
||||
url: '/guides/monitoring-and-debugging/sentry-monitoring' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Tracing with the JS SDK',
|
||||
name: 'Tracing with the client SDKs',
|
||||
url: '/guides/monitoring-and-debugging/client-side-tracing' as `/${string}`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -57,7 +57,9 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>Set your expected parameters</h4>
|
||||
<span className="block font-heading font-semibold text-lg mt-9 mb-[18px] text-foreground">
|
||||
Set your expected parameters
|
||||
</span>
|
||||
<div className="grid mb-8 gap-y-8 gap-x-8 grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<Label htmlFor="computeAddOn">Compute:</Label>
|
||||
@@ -107,7 +109,9 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
|
||||
{limits && (
|
||||
<div className="mt-8">
|
||||
<h4>Current maximum possible throughput</h4>
|
||||
<span className="block font-heading font-semibold text-lg mt-9 mb-[18px] text-foreground">
|
||||
Current maximum possible throughput
|
||||
</span>
|
||||
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
@@ -158,7 +162,9 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
.filter((v, i, a) => a.indexOf(v) === i)
|
||||
.map((computeAddOn) => (
|
||||
<div>
|
||||
<h4>{COMPUTE_LABELS[computeAddOn]}</h4>
|
||||
<span className="block font-heading font-semibold text-lg mt-9 mb-[18px] text-foreground">
|
||||
{COMPUTE_LABELS[computeAddOn]}
|
||||
</span>
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -5,9 +5,7 @@ The next step requires a callback URL, which looks like this: `https://<project-
|
||||
- Click on [`Sign In / Providers`](/dashboard/project/_/auth/providers) under the Configuration section
|
||||
- Click on **{{ .provider }}** from the accordion list to expand and you'll find your **Callback URL**, you can click `Copy` to copy it to the clipboard
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
#### Local development
|
||||
### Local development
|
||||
|
||||
When testing OAuth locally with the Supabase CLI, ensure your OAuth provider
|
||||
is configured with the local Supabase Auth callback URL:
|
||||
@@ -19,5 +17,3 @@ If this callback URL is missing or misconfigured, OAuth sign-in may fail or not
|
||||
See the [local development docs](/docs/guides/local-development) for more details.
|
||||
|
||||
For testing OAuth locally with the Supabase CLI see the [local development docs](/docs/guides/local-development).
|
||||
|
||||
</Admonition>
|
||||
@@ -6,6 +6,12 @@ subtitle: 'Connect your AI coding agent to Supabase.'
|
||||
|
||||
Supabase provides everything you need to connect an AI coding agent to your project: a live connection to your database and platform (MCP), portable instructions your agent can reuse (Agent Skills), a one-step bundle of both (Plugin), and copy-paste prompts for tools that don't support any of the above.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
See how these tools perform on real Supabase tasks in [Supabase Evals](/evals), our open-source benchmark for AI coding agents.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Pick your agent
|
||||
|
||||
<ContentListings id="ai-tools-supported-agents" />
|
||||
|
||||
@@ -18,25 +18,19 @@ Supabase auth audit logs automatically capture all authentication events includi
|
||||
|
||||
## Storage options
|
||||
|
||||
By default, audit logs are stored in two places:
|
||||
Audit logs are stored in:
|
||||
|
||||
1. **Your project's Postgres database** - Stored in the `auth.audit_log_entries` table, searchable via SQL but uses database storage
|
||||
2. **External log storage** - Cost-efficient storage accessible through the dashboard
|
||||
- **External log storage** - Cost-efficient storage accessible through the dashboard
|
||||
- **Postgres database** (optional) - Stored in the `auth.audit_log_entries` table, searchable via SQL, but uses additional database storage
|
||||
|
||||
You can disable Postgres storage to reduce database storage costs while keeping the external log storage.
|
||||
You can enable or disable Postgres database storage to optimize your costs.
|
||||
|
||||
### Configuring audit log storage
|
||||
|
||||
1. Navigate to your project’s dashboard
|
||||
2. Go to **Authentication**
|
||||
3. Find the **Audit Logs** under **Configuration** section
|
||||
4. Toggle on "Disable writing auth audit logs to project database" to disable database storage
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Disabling Postgres storage reduces your database storage costs. Audit logs will still be available through the dashboard.
|
||||
|
||||
</Admonition>
|
||||
3. Find the **Audit Logs** under the **Configuration** section
|
||||
4. Toggle "Write audit logs to the database" on to enable or off to disable database storage
|
||||
|
||||
## Log format
|
||||
|
||||
|
||||
@@ -345,6 +345,70 @@ With Deep Linking, you can configure this redirect to open a specific page. This
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Handling the incoming URL
|
||||
|
||||
Once the OS opens your app via the redirect URL, pass that URL to `supabase.auth.handle(_:)` to complete the sign-in.
|
||||
|
||||
#### SwiftUI
|
||||
|
||||
Use the `onOpenURL` view modifier on your root view:
|
||||
|
||||
```swift
|
||||
SomeView()
|
||||
.onOpenURL { url in
|
||||
supabase.auth.handle(url)
|
||||
}
|
||||
```
|
||||
|
||||
#### UIKit: App delegate
|
||||
|
||||
Forward the URL from `application(_:open:options:)`, and from `didFinishLaunchingWithOptions` if the app was launched cold via the link:
|
||||
|
||||
```swift
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
if let url = launchOptions?[.url] as? URL {
|
||||
supabase.auth.handle(url)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any]
|
||||
) -> Bool {
|
||||
supabase.auth.handle(url)
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
#### UIKit: Scene delegate
|
||||
|
||||
Forward the URL from `SceneDelegate.swift`, handling both a cold launch (via `scene(_:willConnectTo:options:)`) and a URL received while the scene is already running (via `scene(_:openURLContexts:)`):
|
||||
|
||||
```swift
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
for context in connectionOptions.urlContexts {
|
||||
supabase.auth.handle(context.url)
|
||||
}
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
guard let url = URLContexts.first?.url else { return }
|
||||
supabase.auth.handle(url)
|
||||
}
|
||||
```
|
||||
|
||||
`handle(_:)` is a convenience wrapper that calls `session(from:)` and logs any error. See the [Auth API reference](/docs/reference/swift/auth-api) for details, or call `session(from:)` directly if you need the returned `Session` or want control over error handling.
|
||||
|
||||
<$Partial path="universal_links_apple.mdx" />
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -609,6 +609,14 @@ var session = await supabase.Auth.SignIn("valid.email@supabase.io", "example-pas
|
||||
|
||||
### Resetting a password
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
To prevent user enumeration, `resetPasswordForEmail()` doesn't reveal whether an account exists for the given email address.
|
||||
|
||||
When no user is associated with the address, Supabase Auth won't send an email, though the method still returns without an error.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
|
||||
@@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
@@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
@@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
@@ -5,6 +5,8 @@ breadcrumb: 'Auth Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
@@ -164,7 +164,7 @@ For example, if we had a database with some Star Wars data inside:
|
||||
>
|
||||
<TabPanel id="data" label="Data">
|
||||
|
||||
<h4>Planets</h4>
|
||||
### Planets
|
||||
|
||||
```
|
||||
| id | name |
|
||||
@@ -174,7 +174,7 @@ For example, if we had a database with some Star Wars data inside:
|
||||
| 3 | Kashyyyk |
|
||||
```
|
||||
|
||||
<h4>People</h4>
|
||||
### People
|
||||
|
||||
```
|
||||
| id | name | planet_id |
|
||||
|
||||
@@ -920,7 +920,8 @@ struct Shift: Codable {
|
||||
let userId: Int
|
||||
let attendanceStatus: String?
|
||||
|
||||
let scans: [Scan]
|
||||
let startScan: Scan
|
||||
let endScan: Scan
|
||||
|
||||
struct Scan: Codable {
|
||||
let id: Int
|
||||
@@ -938,6 +939,8 @@ struct Shift: Codable {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case attendanceStatus = "attendance_status"
|
||||
case startScan = "start_scan"
|
||||
case endScan = "end_scan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,11 +954,11 @@ let shifts: [Shift] = try await supabase
|
||||
user_id,
|
||||
badge_scan_time
|
||||
),
|
||||
scans: scan_id_end (
|
||||
end_scan:scans!scan_id_end (
|
||||
id,
|
||||
user_id,
|
||||
badge_scan_time
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
.execute()
|
||||
|
||||
@@ -264,6 +264,21 @@ These errors indicate issues with function loading, execution, or the underlying
|
||||
|
||||
**Solution:** Try redeploying your function and wait a few minutes to make sure all regions have been updated.
|
||||
|
||||
### NOT_FOUND_FUNCTION_BLOB
|
||||
|
||||
**Cause:** Your Edge Function metadata resolved, but its deployment bundle was missing from storage and could not be loaded (the metadata points at a different version than the stored bundle). This returns the same `Requested function was not found` message as `NOT_FOUND`, so the `sb-error-code` header is what distinguishes them — see [Edge Function 404 error response](/docs/guides/troubleshooting/edge-function-404-error-response).
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Two deploys of the same function running concurrently, double-incrementing the metadata version
|
||||
- A batch deploy using `/deploy?bundleOnly=true` where the bulk metadata update failed
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Redeploy your function with the latest version of the Supabase CLI
|
||||
- Avoid running concurrent deploys of the same function, such as overlapping GitHub Actions runs
|
||||
- If the problem persists, contact support so your function metadata can be re-synced
|
||||
|
||||
### BOOT_ERROR
|
||||
|
||||
**Cause:** Your Edge Function failed to start.
|
||||
@@ -302,3 +317,12 @@ These errors indicate issues with function loading, execution, or the underlying
|
||||
|
||||
- Try redeploying your function
|
||||
- If the problem persists, contact support
|
||||
|
||||
### LOAD_FUNCTION_UNBUNDLING_ERROR
|
||||
|
||||
**Cause:** Your Edge Function deployment bundle was fetched, but could not be unbundled because decompressing or parsing it failed. This usually means the bundle is corrupt or was only partially written.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Try redeploying your function
|
||||
- If the problem persists, contact support
|
||||
@@ -37,6 +37,7 @@ The following regions are supported:
|
||||
**Europe:**
|
||||
|
||||
- `eu-central-1` (Frankfurt)
|
||||
- `eu-central-2` (Zurich)
|
||||
- `eu-west-1` (Ireland)
|
||||
- `eu-west-2` (London)
|
||||
- `eu-west-3` (Paris)
|
||||
|
||||
@@ -23,7 +23,7 @@ Vercel Marketplace is currently in Public Alpha. If you encounter any issues or
|
||||
### Via template
|
||||
|
||||
<div className="bg-surface-100 py-4 px-5 border rounded-md not-prose">
|
||||
<h5 className="text-foreground">Deploy a Next.js app with Supabase Vercel Storage now</h5>
|
||||
<h4 className="text-foreground">Deploy a Next.js app with Supabase Vercel Storage now</h4>
|
||||
<p className="text-foreground-light mb-3">Uses the Next.js Supabase Starter Template</p>
|
||||
<a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fhello-world">
|
||||
<img src="https://vercel.com/button" alt="Deploy with Vercel" />
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
---
|
||||
id: 'client-side-tracing'
|
||||
title: 'Tracing with the JS SDK'
|
||||
description: 'Propagate W3C trace context from the Supabase JS SDK through Supabase services'
|
||||
title: 'Client-side tracing'
|
||||
description: 'Propagate W3C trace context from the Supabase JS, Swift, and Dart SDKs through Supabase services'
|
||||
---
|
||||
|
||||
The Supabase JS SDK can attach [W3C Trace Context](https://www.w3.org/TR/trace-context/) headers (`traceparent`, `tracestate`, `baggage`) to outgoing requests. The resulting `trace_id` flows through Supabase services and appears in API Gateway and Edge Function logs, so you can correlate client-side spans with the server-side logs they produced — end-to-end, across the network boundary.
|
||||
The Supabase JS, Swift, and Dart SDKs can attach [W3C Trace Context](https://www.w3.org/TR/trace-context/) headers (`traceparent`, `tracestate`, `baggage`) to outgoing requests. The resulting `trace_id` flows through Supabase services and appears in API Gateway and Edge Function logs, so you can correlate client-side spans with the server-side logs they produced — end-to-end, across the network boundary.
|
||||
|
||||
Because the headers follow the W3C standard, any compliant tracing SDK (OpenTelemetry, Sentry, Datadog, Honeycomb, etc.) can pick up the trace on the server side, including in self-hosted collectors.
|
||||
Because the headers follow the W3C standard, any compliant tracing SDK (such as OpenTelemetry, Sentry, Datadog, or Honeycomb) can pick up the trace on the server side, including in self-hosted collectors.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="language"
|
||||
>
|
||||
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -34,7 +44,7 @@ Trace propagation isn't available through the CDN (UMD) build — there's no way
|
||||
|
||||
The SDK reads from whatever `TracerProvider` you register globally — it doesn't configure one for you. If you haven't instrumented your app yet, follow the [OpenTelemetry JavaScript getting started guide](https://opentelemetry.io/docs/languages/js/getting-started/) to install an SDK (`@opentelemetry/sdk-trace-node` for Node, `@opentelemetry/sdk-trace-web` for browsers) and an exporter for your backend (OTLP, Jaeger, Zipkin, or a vendor-specific one).
|
||||
|
||||
The Supabase SDK only takes care of propagating the trace context that's already active when a request is made.
|
||||
The Supabase SDK only propagates the trace context that's already active when a request is made.
|
||||
|
||||
## Enable trace propagation
|
||||
|
||||
@@ -84,18 +94,9 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
||||
| `enabled` | `boolean` | `false` | Enable trace propagation. |
|
||||
| `respectSamplingDecision` | `boolean` | `true` | If `true`, skip propagation when the upstream trace is not sampled. |
|
||||
|
||||
## Correlating with Supabase logs
|
||||
|
||||
Once trace context is flowing through, the `trace_id` appears in:
|
||||
|
||||
- **API Gateway logs** — every request to PostgREST, Auth, Storage, and Realtime
|
||||
- **Edge Function logs** — invocations and any structured logs emitted from within the function
|
||||
|
||||
If you forward Supabase logs to a third-party backend via [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), you can join Supabase logs to your own client and server traces using the shared `trace_id`. This is especially useful for self-hosted setups where you already operate your own OpenTelemetry collector — Supabase logs become first-class citizens in your existing tracing UI.
|
||||
|
||||
## Using a vendor tracing SDK
|
||||
|
||||
Many tracing SDKs are built on top of OpenTelemetry. They work with this guide as long as a W3C-compliant propagator is registered — but propagator behavior varies. Some vendor SDKs inject only their proprietary headers by default and need extra configuration to also emit the standard `traceparent` header. Check your vendor's OTel integration docs for the exact setup.
|
||||
Many tracing SDKs are built on top of OpenTelemetry. They work with this guide as long as a W3C-compliant propagator is registered. Some vendor SDKs inject only their proprietary headers by default and need extra configuration to also emit the standard `traceparent` header. Check your vendor's OTel integration docs for the exact setup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -104,7 +105,116 @@ The SDK never throws when it can't propagate, which keeps it safe to enable but
|
||||
- **The tracing runtime isn't loaded** (version `2.112.0` and later). `tracePropagation` is enabled but your entry point never imports `@supabase/supabase-js/tracing`. The SDK logs a one-time console warning and sends requests without trace headers — look for that warning in your console.
|
||||
- **No active span at request time.** The SDK reads the _current_ context. If `supabase.from(...)` is called outside `tracer.startActiveSpan(...)` (or equivalent), there's nothing to propagate. Wrap the call in a span or use OpenTelemetry's automatic instrumentation.
|
||||
- **`@opentelemetry/api` is not installed** in the app making the request. On `2.112.0` and later the tracing subpath imports it directly, so a missing package surfaces as a module resolution error. On `2.106.0`–`2.111.x` it's loaded dynamically and the SDK silently no-ops.
|
||||
- **No `TracerProvider` registered.** `@opentelemetry/api` defaults to a noop provider that produces non-recorded spans. Make sure your app calls `provider.register()` (or your vendor SDK's equivalent) before making requests.
|
||||
- **No `TracerProvider` registered.** `@opentelemetry/api` defaults to a noop provider that produces non-recorded spans. Ensure your app calls `provider.register()` (or your vendor SDK's equivalent) before making requests.
|
||||
- **The upstream trace is not sampled.** By default the SDK respects upstream sampling decisions. Set `respectSamplingDecision: false` to propagate every request regardless of sampling.
|
||||
- **You're calling a non-Supabase host through a custom `fetch`.** Trace headers are only attached to Supabase domains (`*.supabase.co`, `*.supabase.in`, `localhost`).
|
||||
- **You're using the CDN (UMD) build.** Trace propagation isn't available there — the tracing runtime can't be loaded from a script tag.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
Requires `supabase-swift` `2.51.0` or later and `swift-tools-version: 6.1` or later (SwiftPM trait support).
|
||||
|
||||
1. **Add the `OpenTelemetry` trait** to your dependency declaration in `Package.swift`:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
.package(
|
||||
url: "https://github.com/supabase/supabase-swift.git",
|
||||
from: "2.51.0",
|
||||
traits: ["OpenTelemetry"]
|
||||
)
|
||||
```
|
||||
|
||||
No changes to `SupabaseClient` are required. After enabling the trait, the active OpenTelemetry span's trace context is automatically injected as a `traceparent` header on every outgoing request across PostgREST, Storage, Auth, Functions, and Realtime. When there is no active span, the header is not added.
|
||||
|
||||
2. **Register a `TracerProvider`** at app start. The SDK reads from whatever provider you register globally:
|
||||
|
||||
```swift
|
||||
import Supabase
|
||||
import OpenTelemetryApi
|
||||
import OpenTelemetrySdk
|
||||
|
||||
let exporter = /* your OTLP / Jaeger / Zipkin exporter */
|
||||
let spanProcessor = SimpleSpanProcessor(spanExporter: exporter)
|
||||
let provider = TracerProviderBuilder()
|
||||
.add(spanProcessor: spanProcessor)
|
||||
.build()
|
||||
OpenTelemetry.registerTracerProvider(tracerProvider: provider)
|
||||
```
|
||||
|
||||
3. **Create your `SupabaseClient`**. Any active span is now propagated automatically:
|
||||
|
||||
```swift
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://xyzcompany.supabase.co")!,
|
||||
supabaseKey: "your-publishable-key"
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="dart" label="Dart">
|
||||
|
||||
Requires `supabase` `2.x` or later (Flutter or Dart-only).
|
||||
|
||||
1. **Implement a `traceContextProvider`** that returns the current `TraceContext` from your tracing library. Return `null` when there is no active span.
|
||||
|
||||
2. **Pass `TracePropagationOptions`** when creating the client:
|
||||
|
||||
```dart
|
||||
import 'package:supabase/supabase.dart';
|
||||
|
||||
final supabase = SupabaseClient(
|
||||
'https://xyzcompany.supabase.co',
|
||||
'your-publishable-key',
|
||||
tracePropagationOptions: TracePropagationOptions(
|
||||
enabled: true,
|
||||
traceContextProvider: () {
|
||||
final span = YourTracer.activeSpan;
|
||||
if (span == null) return null;
|
||||
return TraceContext(
|
||||
traceparent: span.traceparent,
|
||||
tracestate: span.tracestate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
For `supabase_flutter`, pass the same option through `Supabase.initialize`:
|
||||
|
||||
```dart
|
||||
await Supabase.initialize(
|
||||
url: 'https://xyzcompany.supabase.co',
|
||||
anonKey: 'your-publishable-key',
|
||||
tracePropagationOptions: TracePropagationOptions(
|
||||
enabled: true,
|
||||
traceContextProvider: () => yourTraceContextProvider(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------- | ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `bool` | `false` | Enable trace propagation. |
|
||||
| `respectSamplingDecision` | `bool` | `true` | When `true`, skips propagation if the upstream trace is not sampled. Set to `false` to always attach a `trace_id` — useful for log correlation even when traces are not exported. |
|
||||
| `traceContextProvider` | `TraceContextProvider?` | `null` | Callback returning the current `TraceContext`. Return `null` when there is no active span. |
|
||||
|
||||
Headers are only injected on requests targeting Supabase hosts (`*.supabase.co`, `*.supabase.in`, your project host, and loopback addresses for local development). Third-party hosts never receive trace headers.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Correlating with Supabase logs
|
||||
|
||||
After trace context is flowing through, the `trace_id` appears in:
|
||||
|
||||
- **API Gateway logs** — every request to PostgREST, Auth, Storage, and Realtime
|
||||
- **Edge Function logs** — invocations and any structured logs emitted from within the function
|
||||
|
||||
If you forward Supabase logs to a third-party backend via [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), you can join Supabase logs to your own client and server traces using the shared `trace_id`. This is especially useful for self-hosted setups where you already operate your own OpenTelemetry collector — Supabase logs become first-class citizens in your existing tracing UI.
|
||||
@@ -51,7 +51,7 @@ curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-acce
|
||||
|
||||
Once temporary access has been enabled, project users must be authorized and mapped to Postgres roles they are allowed to access. Each user can be authorized to "assume" one or more Postgres roles using temporary access.
|
||||
|
||||
When a user is authorized to assume a Postgres role, the user's access token (Personal Access Token (PAT) or Scoped PAT) will be used as the password for the Postgres role.
|
||||
When a user is authorized to assume a Postgres role, the user's Personal Access Token (PAT) will be used as the password for the Postgres role.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
|
||||
@@ -310,8 +310,7 @@ changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -681,8 +680,7 @@ changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"INSERT",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -693,8 +691,7 @@ changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -705,8 +702,7 @@ changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"DELETE",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -717,8 +713,7 @@ changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -903,8 +898,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
schema="public",
|
||||
table="todos",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -1026,7 +1020,7 @@ myChannel.subscribe()
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
table="messages"
|
||||
table="messages",
|
||||
callback=lambda payload: print(payload)
|
||||
).on_postgres_changes(
|
||||
"INSERT",
|
||||
@@ -1162,8 +1156,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="todos",
|
||||
filter="id=eq.1",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -1360,8 +1353,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="messages",
|
||||
filter="body=eq.hey",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -1524,8 +1516,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="messages",
|
||||
filter="body=neq.bye",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -1687,8 +1678,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="profiles",
|
||||
filter="age=lt.65",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -1850,8 +1840,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="profiles",
|
||||
filter="age=lte.65",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2013,8 +2002,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="products",
|
||||
filter="quantity=gt.10",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2176,8 +2164,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="products",
|
||||
filter="quantity=gte.10",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2339,8 +2326,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="products",
|
||||
filter="name=in.(red,blue,yellow)",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2503,8 +2489,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="articles",
|
||||
filter="title=ilike.%breaking%",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2667,8 +2652,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="posts",
|
||||
filter="slug=match.^post-\\d+$",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2831,8 +2815,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="todos",
|
||||
filter="completed_at=is.null",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -2995,8 +2978,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="orders",
|
||||
filter="status=isdistinct.shipped",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -3158,8 +3140,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="posts",
|
||||
filter="status=not.in.(draft,archived)",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -3332,8 +3313,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="orders",
|
||||
filter="amount=gt.100,status=eq.open",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -3428,6 +3408,21 @@ let changes = await myChannel.postgresChange(
|
||||
await myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<$Show if="sdk:python">
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
table="profiles",
|
||||
select=["id", "username"],
|
||||
callback=lambda payload: print(payload)
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
</Tabs>
|
||||
@@ -3443,7 +3438,7 @@ alter table
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
RLS policies are not applied to `DELETE` statements, because there is no way for Postgres to verify that a user has access to a deleted record. When RLS is enabled and `replica identity` is set to `full` on a table, the `old` record contains only the primary key(s).
|
||||
RLS policies are not applied to `DELETE` statements, because there is no way for Postgres to verify that a user has access to a deleted record.
|
||||
|
||||
</Admonition>
|
||||
|
||||
@@ -3595,8 +3590,7 @@ changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
table="products",
|
||||
filter="name=in.(red,blue,yellow)",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -3625,9 +3619,9 @@ await channel.Subscribe();
|
||||
|
||||
## Limitations
|
||||
|
||||
### Delete events are not filterable
|
||||
### Delete events
|
||||
|
||||
You can't filter Delete events when tracking Postgres Changes. This limitation is due to the way changes are pulled from Postgres.
|
||||
You can only filter Delete events when tracking Postgres Changes if the table has the `replica identity` set to `full`. See [Receiving old records](#receiving-old-records).
|
||||
|
||||
## Scaling Postgres Changes
|
||||
|
||||
|
||||
@@ -7,14 +7,21 @@ hideToc: true
|
||||
|
||||
Self-hosting is a good fit if you need full control over your data, have compliance requirements that prevent you from using managed services, or want to run Supabase in an isolated environment.
|
||||
|
||||
<ContentListings id="self-hosting-get-started" />
|
||||
|
||||
<ContentListings id="self-hosting-community" />
|
||||
|
||||
## How self-hosted Supabase differs
|
||||
|
||||
Self-hosted Supabase is different from:
|
||||
Self-hosted Supabase runs as a single project which means that Studio doesn't support multiple organizations or projects. Most settings are configured through [environment variables](https://github.com/supabase/supabase/blob/master/docker/.env.example).
|
||||
|
||||
- **Supabase CLI** (local development), which is intended for development and testing only.
|
||||
- **Managed Supabase** platform, which is fully hosted and operated by Supabase.
|
||||
Unlike the managed platform, which is fully hosted and operated by Supabase, branching, advanced metrics beyond logs, managed backups and PITR, analytics and vector buckets, ETL, and the platform management API are **unavailable**.
|
||||
|
||||
Self-hosted Supabase mimics a single project. Studio doesn't support multiple organizations or projects. Platform-only [features](/features) such as branching, advanced metrics beyond logs, managed backups and PITR, analytics and vector buckets, ETL, and the platform management API are **unavailable** in self-hosted configuration. Most settings are configured through [environment variables](https://github.com/supabase/supabase/blob/master/docker/.env.example).
|
||||
### Not the same as local development
|
||||
|
||||
[Supabase CLI](/docs/guides/local-development/cli/getting-started) runs a local stack for development and testing. That stack is not a self-hosted deployment: it is not hardened for production and must not be exposed to external traffic.
|
||||
|
||||
To self-host, use [Docker](/docs/guides/self-hosting/docker) or one of the community deployment options.
|
||||
|
||||
## Your responsibilities when self-hosting
|
||||
|
||||
@@ -32,21 +39,13 @@ When you self-host, **you are responsible for**:
|
||||
|
||||
Self-hosted Supabase (run via Docker Compose) **does not phone home or collect any telemetry**.
|
||||
|
||||
The **Supabase CLI** is a [separate tool](/docs/guides/local-development/cli/getting-started) and collects usage telemetry to help improve the developer experience. See [CLI telemetry](/docs/guides/local-development/cli/getting-started#telemetry) for opt-out methods.
|
||||
|
||||
<ContentListings id="self-hosting-get-started" />
|
||||
|
||||
<ContentListings id="self-hosting-community" />
|
||||
The **Supabase CLI**, a separate tool also used for [local development](/docs/guides/local-development/cli/getting-started), collects usage telemetry to help improve the developer experience. See [CLI telemetry](/docs/guides/local-development/cli/getting-started#telemetry) for opt-out methods.
|
||||
|
||||
## Support and community
|
||||
|
||||
Self-hosted Supabase is community-supported.
|
||||
|
||||
<ContentListings id="self-hosting-resolve-issues" />
|
||||
|
||||
<ContentListings id="self-hosting-get-help" />
|
||||
|
||||
<ContentListings id="self-hosting-share-experience" />
|
||||
<ContentListings id="self-hosting-support" />
|
||||
|
||||
### Enterprise self-hosting
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ The script supports Linux only (Debian/Ubuntu and RHEL/CentOS/Fedora) and will:
|
||||
- Install prerequisites (`git`, `openssl`, `jq`) and Docker Engine if not already present
|
||||
- Sparse-clone the `docker/` directory from the main Supabase [repository](https://github.com/supabase/supabase/)
|
||||
- Create a project directory (`supabase-project` by default) and copy the configuration files into it
|
||||
- Record the installed release version in `.supabase-version` for future `update.sh` upgrades
|
||||
- Prompt for the main URLs (`SUPABASE_PUBLIC_URL`, `API_EXTERNAL_URL`, `SITE_URL`, `PROXY_DOMAIN`) and write them to `.env`
|
||||
- Generate all secrets, including a random `DASHBOARD_PASSWORD`, and the asymmetric JWT signing key pair (runs `generate-keys.sh` and `add-new-auth-keys.sh`, and enables the matching entries in `docker-compose.yml`)
|
||||
- Pull the Docker images
|
||||
@@ -105,7 +106,7 @@ Not on Linux, or want to do it manually? See [Manual installation](#manual-insta
|
||||
|
||||
### Manual installation
|
||||
|
||||
This path gets the Docker Compose configuration onto your server; you'll configure secrets, keys, and URLs in the [next section](#configuring-and-securing-supabase).
|
||||
This path gets the Docker Compose configuration onto your server, pinned to a specific tag. You'll set up secrets, keys, and URLs in the [next section](#configuring-and-securing-supabase). For a newer release, use the [latest tag](https://github.com/supabase/supabase/tags).
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -121,7 +122,7 @@ A shallow clone of the full Supabase repository. Works on any OS with `git` inst
|
||||
|
||||
```sh
|
||||
# Get the code
|
||||
git clone --depth 1 https://github.com/supabase/supabase
|
||||
git clone --depth 1 --branch self-hosted/v0.7.2 https://github.com/supabase/supabase
|
||||
|
||||
# Make your new supabase project directory
|
||||
mkdir supabase-project
|
||||
@@ -137,6 +138,9 @@ cp -rf supabase/docker/. supabase-project
|
||||
# Switch to the project directory and create a .env from the example
|
||||
cd supabase-project && cp .env.example .env
|
||||
|
||||
# Record the base version so update.sh can upgrade this install later
|
||||
printf 'ref=self-hosted/v0.7.2\n' > .supabase-version
|
||||
|
||||
# Pull the latest images
|
||||
docker compose pull
|
||||
```
|
||||
@@ -149,7 +153,7 @@ Only downloads the `docker/` directory from the repository, saving bandwidth and
|
||||
|
||||
```sh
|
||||
# Get the code using git sparse checkout
|
||||
git clone --filter=blob:none --no-checkout --depth=1 --quiet https://github.com/supabase/supabase
|
||||
git clone --filter=blob:none --no-checkout --depth=1 --quiet --branch self-hosted/v0.7.2 https://github.com/supabase/supabase
|
||||
cd supabase
|
||||
git sparse-checkout init --cone
|
||||
git sparse-checkout set docker
|
||||
@@ -170,6 +174,9 @@ cp -rf supabase/docker/. supabase-project
|
||||
# Switch to the project directory and create a .env from the example
|
||||
cd supabase-project && cp .env.example .env
|
||||
|
||||
# Record the base version so update.sh can upgrade this install later
|
||||
printf 'ref=self-hosted/v0.7.2\n' > .supabase-version
|
||||
|
||||
# Pull the latest images
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title = "Troubleshooting MIGRATIONS_FAILED: missing tables or an incomplete schema on your branch"
|
||||
topics = [ "branching" ]
|
||||
keywords = [ "branching", "migrations", "MIGRATIONS_FAILED", "failed" ]
|
||||
database_id = ""
|
||||
---
|
||||
|
||||
When a Preview Branch is created through the Dashboard, it's built by replaying the migration history from your `main` branch against a fresh database. If that replay fails partway through, the branch is left either empty or partly complete and its status shows `MIGRATIONS_FAILED`. This almost always means the migration history on `main` is out of sync with its actual live schema, commonly because a change was made directly in the SQL Editor or through another manual edit that was never captured as a migration file.
|
||||
|
||||
Work through the following steps to diagnose and repair your migration history so branching can complete successfully.
|
||||
|
||||
---
|
||||
|
||||
## 1. Confirm the branch failure and view its workflow
|
||||
|
||||
1. Go to [Branches](/dashboard/project/_/branches) and find the affected branch.
|
||||
2. Click **View logs**. A dialog shows the branch's creation workflow, including the failed step.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 2. Find the exact SQL error in your Postgres logs
|
||||
|
||||
1. Go to [Postgres Logs](/dashboard/project/_/logs?filter=log_type:eq:postgres).
|
||||
2. Look for entries beginning with `execute <unnamed>:`. These are the individual migration statements being replayed.
|
||||
3. Find the entry marked as an **error**, which the Dashboard also highlights in red. This is the statement that failed, and its message explains why. Common messages include relation already exists, column not found, permission denied, and relation does not exist.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3. Prepare a local Supabase environment
|
||||
|
||||
If you don't already have one, follow the [local development getting started guide](/docs/guides/local-development/cli/getting-started), then link your project:
|
||||
|
||||
```bash
|
||||
supabase link
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Pull down your production migration history
|
||||
|
||||
```bash
|
||||
supabase migration fetch
|
||||
```
|
||||
|
||||
This retrieves the migration files currently recorded against your `main` branch, so you can compare them against the actual live schema and locate the one causing the failure.
|
||||
|
||||
---
|
||||
|
||||
## 5. Repair the culprit migration
|
||||
|
||||
1. Locate the migration file containing the SQL statement that matches the error you found in step 2, and correct it so it reflects the real state of your production schema.
|
||||
2. After correcting the file, mark it as applied using its timestamp:
|
||||
|
||||
```bash
|
||||
supabase migration repair <timestamp> --status applied
|
||||
```
|
||||
|
||||
3. Verify the repair took effect by checking [production migrations](/dashboard/project/_/database/migrations) in the Dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 6. Re-test branch creation
|
||||
|
||||
After repairing the migration history, either create a new branch or **rebase** the existing affected branch.
|
||||
|
||||

|
||||
|
||||
Check the branch's workflow logs again, as described in step 1, to confirm migrations now complete successfully.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Branch creation can fail more than once because more than one migration is out of sync. Each failure surfaces the next problem statement, so repeat steps 2 through 6 to work through them one at a time.
|
||||
|
||||
</Admonition>
|
||||
|
||||
---
|
||||
|
||||
## Additional tips
|
||||
|
||||
If issues persist after repairing migration history, such as schema drift or repeated mismatches, review the [branching troubleshooting documentation](/docs/guides/deployment/branching/troubleshooting#migration-issues) and consider further manual repair with [`supabase migration repair`](/docs/reference/cli/supabase-migration-repair).
|
||||
|
||||
If your migration history has drifted too far out of sync for repairing individual migrations to be practical, consider creating a single baseline migration that encapsulates your production project's current schema instead. The [new branch doesn't copy database troubleshooting guide](/docs/guides/troubleshooting/new-branch-doesnt-copy-database) walks through the commands for setting this up.
|
||||
@@ -8,9 +8,14 @@ database_id = "a3cfff33-d640-4395-99bc-a110b7fb637c"
|
||||
http_status_code = 404
|
||||
code = "NOT_FOUND"
|
||||
message = "Requested function was not found"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 404
|
||||
code = "NOT_FOUND_FUNCTION_BLOB"
|
||||
message = "Function deployment bundle not found"
|
||||
---
|
||||
|
||||
The edge function is not recognized by Supabase
|
||||
The edge function is not recognized by Supabase, or its deployed bundle cannot be found.
|
||||
|
||||
## Context for the error
|
||||
|
||||
@@ -51,6 +56,20 @@ When an edge function fails due to a platform 404 error, it will return the erro
|
||||
}
|
||||
```
|
||||
|
||||
A different platform 404 error, `NOT_FOUND_FUNCTION_BLOB`, can occur when the function name is recognized but the runtime cannot find the deployed bundle. This can happen even when the function appears as `ACTIVE` in the dashboard, because the status shows that the function record exists while the actual deployment bundle is missing or inaccessible:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "NOT_FOUND_FUNCTION_BLOB",
|
||||
"message": "Function deployment bundle not found"
|
||||
}
|
||||
```
|
||||
|
||||
The difference between the two errors is:
|
||||
|
||||
- `NOT_FOUND`: The function name in the request URL is not recognized.
|
||||
- `NOT_FOUND_FUNCTION_BLOB`: The function name is recognized, but the runtime cannot find the deployed bundle.
|
||||
|
||||
### Inspecting the logs
|
||||
|
||||
<Admonition type='note'>
|
||||
@@ -99,20 +118,23 @@ If the call works, consider double checking your code for typos or to see if it
|
||||
|
||||
### Step 4: Redeploy the function
|
||||
|
||||
If step 3 fails, it may be a sign of an internal bug and it may be necessary to redeploy your function. This can be done within the [Function Dashboard](/dashboard/project/_/functions) under the respective function's code tab:
|
||||
If step 3 fails, it may be a sign of an internal bug and it may be necessary to redeploy your function. The same applies if you see the `NOT_FOUND_FUNCTION_BLOB` error, which means the function is recognized but its deployed bundle cannot be found. This can occur even when the function appears as `ACTIVE` in the dashboard.
|
||||
|
||||
This can be done within the [Function Dashboard](/dashboard/project/_/functions) under the respective function's code tab:
|
||||
|
||||

|
||||
|
||||
Alternatively, if you develop locally, you can redeploy the function with the [Supabase CLI](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos):
|
||||
|
||||
```sh
|
||||
# Redeploy one affected function
|
||||
supabase functions deploy FUNCTION_NAME
|
||||
|
||||
# If you want to deploy all functions, run the `deploy` command without specifying a function name:
|
||||
# Or redeploy all functions if several are affected
|
||||
supabase functions deploy
|
||||
```
|
||||
|
||||
Then write in a ticket to [Supabase Support](/dashboard/support/new)
|
||||
After redeploying, retry the request. If the error persists, write in a ticket to [Supabase Support](/dashboard/support/new).
|
||||
|
||||
## Additional resources
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title = "Supabase CLI fails with 'Transport error' (antivirus/proxy TLS interception)"
|
||||
topics = [ "cli", "platform" ]
|
||||
keywords = [ "windows", "norton", "tls interception", "certificate", "transport error", "ssl inspection", "firewall", "web and mail shield", "zscaler", "netskope" ]
|
||||
|
||||
[[errors]]
|
||||
message = "Transport error"
|
||||
|
||||
[[errors]]
|
||||
message = "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
|
||||
---
|
||||
|
||||
This typically affects CLI commands that call the Management API, such as `supabase projects list` and `supabase link`:
|
||||
|
||||
```
|
||||
failed to list projects: HttpClientError: Transport error (GET https://api.supabase.com/v1/projects)
|
||||
```
|
||||
|
||||
## Why this happens
|
||||
|
||||
The CLI's HTTP client buckets any failure that happens before it receives an HTTP response — timeouts, connection resets, certificate rejections — under one generic `TransportError` label. The message alone doesn't tell you which of these occurred, which makes this error hard to diagnose from the output alone.
|
||||
|
||||
One confirmed cause: antivirus software or corporate SSL-inspecting proxies that intercept HTTPS traffic to scan it. These install a local root certificate and re-sign connections so they can decrypt, inspect, and re-encrypt traffic in transit — a documented feature of products like Norton (under names like "Web and Mail Shield" or "Safe Web") and of enterprise network security proxies. The CLI receives the substituted certificate instead of Supabase's real one and rejects it, surfacing as the generic transport error above rather than a certificate-specific one.
|
||||
|
||||
Other HTTP clients on the same machine can behave differently, which is a useful diagnostic signal. In one confirmed case (Norton 360 on Windows), `curl` connected successfully while the CLI failed, and Node's `fetch()` surfaced a more specific `UNABLE_TO_VERIFY_LEAF_SIGNATURE` error for the same request.
|
||||
|
||||
## How to diagnose
|
||||
|
||||
- Compare behavior across HTTP clients on the same machine. If `curl -v https://api.supabase.com` or a browser succeeds while the CLI fails, the network path itself is fine and something local to the machine is the difference.
|
||||
- Check the certificate being served for `api.supabase.com`. In a browser, click the padlock icon next to the address bar and check the certificate's **Issuer** field. It should be a public CA (e.g. Google Trust Services). If it's your antivirus vendor or a corporate proxy CA instead, that's what's intercepting the connection.
|
||||
- If you're on a corporate network, check whether your organization uses an SSL-inspecting proxy and whether its root CA is installed and trusted on your machine.
|
||||
|
||||
## How to resolve
|
||||
|
||||
Only disable HTTPS/TLS inspection after the diagnostic step above confirms it's the cause — a `Transport error` can also come from an unrelated timeout or connection reset, and disabling scanning you don't need reduces your protection unnecessarily.
|
||||
|
||||
Once confirmed, identify and disable the specific feature doing the inspection. This is often a separate setting from the general firewall or "protection" toggle, so disabling the main antivirus switch alone may not be enough. Treat this as temporary while you resolve connectivity — prefer a narrower host-specific exclusion for `api.supabase.com` over disabling scanning outright, if your software supports one.
|
||||
|
||||
For **Norton 360 on Windows**, the responsible feature is **Safe Web / Web & Mail Shield**, not Smart Firewall — disabling Smart Firewall alone does not stop the interception. After disabling it, confirm the certificate reverts to the expected issuer before retrying.
|
||||
|
||||
If you're behind a corporate SSL-inspecting proxy, this is managed by your IT/security team rather than something you can change locally — they can add an exclusion for `api.supabase.com` on the proxy side if needed.
|
||||
@@ -23,10 +23,8 @@ import { realtimeExamples, realtimeGetStarted, realtimeResources } from './realt
|
||||
import { resourcesMigrate, resourcesOverview, resourcesPostgres } from './resources.data'
|
||||
import {
|
||||
selfHostingCommunity,
|
||||
selfHostingGetHelp,
|
||||
selfHostingGetStarted,
|
||||
selfHostingResolveIssues,
|
||||
selfHostingShareExperience,
|
||||
selfHostingSupport,
|
||||
} from './self-hosting.data'
|
||||
import { storageExamples, storageGetStarted, storageResources } from './storage.data'
|
||||
import { telemetryDebugging, telemetryMonitoring } from './telemetry.data'
|
||||
@@ -59,9 +57,7 @@ const ALL_GROUPS: readonly ContentListingGroup[] = [
|
||||
resourcesPostgres,
|
||||
selfHostingGetStarted,
|
||||
selfHostingCommunity,
|
||||
selfHostingResolveIssues,
|
||||
selfHostingGetHelp,
|
||||
selfHostingShareExperience,
|
||||
selfHostingSupport,
|
||||
storageGetStarted,
|
||||
storageExamples,
|
||||
storageResources,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const selfHostingCommunity: ContentListingGroup = {
|
||||
type: 'grid',
|
||||
columns: 2,
|
||||
description:
|
||||
"There are several other options to deploy Supabase. If you're interested in helping these projects, visit our Community page.",
|
||||
"There are several other options to deploy Supabase. If you're interested in helping these projects, visit our [Community page](https://supabase.com/contribute).",
|
||||
items: [
|
||||
{
|
||||
title: 'Kubernetes',
|
||||
@@ -44,9 +44,8 @@ export const selfHostingCommunity: ContentListingGroup = {
|
||||
],
|
||||
}
|
||||
|
||||
export const selfHostingResolveIssues: ContentListingGroup = {
|
||||
id: 'self-hosting-resolve-issues',
|
||||
description: 'For resolving common issues:',
|
||||
export const selfHostingSupport: ContentListingGroup = {
|
||||
id: 'self-hosting-support',
|
||||
type: 'grid',
|
||||
columns: 2,
|
||||
items: [
|
||||
@@ -54,51 +53,33 @@ export const selfHostingResolveIssues: ContentListingGroup = {
|
||||
title: 'GitHub Discussions',
|
||||
href: 'https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted',
|
||||
icon: '/docs/img/icons/github-icon',
|
||||
description: 'Questions, feature requests, and workarounds',
|
||||
description: 'Ask questions, resolve common issues, and make feature requests',
|
||||
},
|
||||
{
|
||||
title: 'GitHub Issues',
|
||||
href: 'https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted',
|
||||
icon: '/docs/img/icons/github-icon',
|
||||
description: 'Known issues',
|
||||
description: 'Find out about known issues and workarounds',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const selfHostingGetHelp: ContentListingGroup = {
|
||||
id: 'self-hosting-get-help',
|
||||
description: 'Get help and connect with other users:',
|
||||
type: 'grid',
|
||||
columns: 2,
|
||||
items: [
|
||||
{
|
||||
title: 'Discord',
|
||||
href: 'https://discord.supabase.com',
|
||||
icon: '/docs/img/icons/discord-icon',
|
||||
hasLightIcon: false,
|
||||
description: 'Real-time chat and community support',
|
||||
description: 'Connect with other users and get help',
|
||||
},
|
||||
{
|
||||
title: 'Reddit',
|
||||
href: 'https://www.reddit.com/r/Supabase/',
|
||||
icon: '/docs/img/icons/reddit-icon',
|
||||
hasLightIcon: false,
|
||||
description: 'Official Supabase subreddit',
|
||||
description: 'Join the official Supabase subreddit',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const selfHostingShareExperience: ContentListingGroup = {
|
||||
id: 'self-hosting-share-experience',
|
||||
description: 'Share your self-hosting experience:',
|
||||
type: 'grid',
|
||||
columns: 2,
|
||||
items: [
|
||||
{
|
||||
title: 'GitHub Discussions',
|
||||
title: 'Share your experience',
|
||||
href: 'https://github.com/orgs/supabase/discussions/39820',
|
||||
icon: '/docs/img/icons/github-icon',
|
||||
description: "Self-hosting: What's working (and what's not)?",
|
||||
description: 'Share your self-hosting experience',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -22,7 +22,7 @@ hideTitle: true
|
||||
There are two ways to generate an access token:
|
||||
|
||||
1. **Personal access token (PAT):**
|
||||
PATs are long-lived tokens that you manually generate to access the Management API. They are useful for automating workflows or developing against the Management API. PATs carry the same privileges as your user account, so be sure to keep it secret.
|
||||
PATs are tokens with a custom expiry that you manually generate to access the Management API. They are useful for automating workflows or developing against the Management API. PATs carry the same privileges as your user account, so be sure to keep it secret.
|
||||
|
||||
To generate or manage your personal access tokens, visit your [account](/dashboard/account/tokens) page.
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ const GuideTemplate = ({
|
||||
<article
|
||||
// Used to get headings for the table of contents
|
||||
id="sb-docs-guide-main-article"
|
||||
data-testid="sb-docs-guide-main-article"
|
||||
className="prose max-w-none"
|
||||
>
|
||||
<header className="mb-8">
|
||||
|
||||
@@ -20,7 +20,11 @@ export default async function TroubleshootingPage({ entry }: { entry: ITroublesh
|
||||
>
|
||||
<div className="px-5 py-8 lg:px-0 lg:py-0">
|
||||
<Breadcrumbs minLength={1} forceDisplayOnMobile />
|
||||
<article className="prose max-w-none mt-4">
|
||||
<article
|
||||
id="sb-docs-troubleshooting-main-article"
|
||||
data-testid="sb-docs-troubleshooting-main-article"
|
||||
className="prose max-w-none mt-4"
|
||||
>
|
||||
<h1>{entry.data.title}</h1>
|
||||
{dateUpdated && (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
|
||||
@@ -14,6 +14,7 @@ export function GuideArticle({ children, className }: GuideArticleProps) {
|
||||
<article
|
||||
// Used to get headings for the table of contents
|
||||
id="sb-docs-guide-main-article"
|
||||
data-testid="sb-docs-guide-main-article"
|
||||
className={cn('prose max-w-none', className)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -104,6 +104,7 @@ Fady A
|
||||
Fatuma Abdullahi
|
||||
Felipe Stival
|
||||
Ferhat Elmas
|
||||
Filipe Cabaco
|
||||
Firas El Rachidi
|
||||
Francesco Sansalvadore
|
||||
Gabriel Claudino
|
||||
@@ -203,6 +204,7 @@ Matthew Hambright
|
||||
Matt Hudson
|
||||
Matt Johnston
|
||||
Matt Rossman
|
||||
Matt Smiley
|
||||
Matthias Luft
|
||||
Mats Kindahl
|
||||
Mert Yerekapan
|
||||
@@ -295,6 +297,7 @@ Tim Frietas
|
||||
Tim Palmer
|
||||
Timothy Lim
|
||||
Tina Ha
|
||||
Tobias Pfeiffer
|
||||
Tom Ashley
|
||||
Tom G
|
||||
Tomás Pozo
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -58,10 +58,10 @@ const OUT_PATH = join(VERSION_DIR, 'supabase_flutter.json')
|
||||
const HEADER_IDS = new Set([
|
||||
'auth-api',
|
||||
'auth-mfa-api',
|
||||
'oauth-server-api',
|
||||
'passkey-api',
|
||||
'admin-api',
|
||||
'admin-passkey-api',
|
||||
'oauth-server-api',
|
||||
'admin-custom-providers-api',
|
||||
'functions-api',
|
||||
'database-api',
|
||||
|
||||
@@ -189,6 +189,14 @@
|
||||
"parent": "filters",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"id": "notin",
|
||||
"title": "Column is not in an array",
|
||||
"slug": "notin",
|
||||
"product": "database",
|
||||
"parent": "filters",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"id": "contains",
|
||||
"title": "Column contains every element in a value",
|
||||
@@ -399,6 +407,14 @@
|
||||
"product": "database",
|
||||
"parent": "modifiers",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"id": "dry-run",
|
||||
"title": "Test a mutation without committing it",
|
||||
"slug": "dryrun",
|
||||
"product": "database",
|
||||
"parent": "modifiers",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1633,6 +1633,34 @@ functions:
|
||||
}
|
||||
```
|
||||
|
||||
- id: notin
|
||||
title: notIn()
|
||||
description: |
|
||||
Finds all rows whose value on the stated `column` is not found on the specified `values`. The negation of `in_()`.
|
||||
params:
|
||||
- name: column
|
||||
isOptional: false
|
||||
type: String
|
||||
description: The column to filter on.
|
||||
- name: values
|
||||
isOptional: false
|
||||
type: List<Any>
|
||||
description: The values to exclude.
|
||||
examples:
|
||||
- id: with-select
|
||||
name: With `select()`
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```kotlin
|
||||
supabase.from("cities").select(columns = Columns.list("name")) {
|
||||
filter {
|
||||
City::name notIn listOf("Hobbiton", "Edoras")
|
||||
//or
|
||||
notIn("name", listOf("Hobbiton", "Edoras"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- id: contains
|
||||
title: contains()
|
||||
description: |
|
||||
@@ -2776,6 +2804,69 @@ functions:
|
||||
hideCodeBlock: true
|
||||
isSpotlight: true
|
||||
|
||||
- id: maybe-single
|
||||
title: maybeSingle()
|
||||
$ref: '@supabase/postgrest-js.PostgrestTransformBuilder.maybeSingle'
|
||||
examples:
|
||||
- id: with-select
|
||||
name: With `select()`
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```kotlin
|
||||
val result = supabase.from("characters").select(Columns.list("name")) {
|
||||
limit(1)
|
||||
maybeSingle()
|
||||
}
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
characters (id int8 primary key, name text);
|
||||
|
||||
insert into
|
||||
characters (id, name)
|
||||
values
|
||||
(1, 'Luke'),
|
||||
(2, 'Leia'),
|
||||
(3, 'Han');
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "Luke"
|
||||
},
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
}
|
||||
```
|
||||
hideCodeBlock: true
|
||||
|
||||
- id: dry-run
|
||||
title: dryRun()
|
||||
description: |
|
||||
Executes the mutation but rolls back the transaction instead of committing it, so no changes are persisted.
|
||||
notes: |
|
||||
- The mutation runs and its result (including side effects such as triggers) is returned in the response, but the transaction is rolled back afterward.
|
||||
- Useful for testing mutations without touching real data.
|
||||
- Requires PostgREST's `db-tx-end` setting to allow client-controlled transaction rollback.
|
||||
examples:
|
||||
- id: with-update
|
||||
name: With `update()`
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```kotlin
|
||||
val toUpdate = City(name = "Mordor")
|
||||
supabase.from("cities").update(toUpdate) {
|
||||
filter {
|
||||
City::name eq "Gondor"
|
||||
}
|
||||
dryRun()
|
||||
}
|
||||
// Row is not actually updated in the database.
|
||||
```
|
||||
|
||||
- id: csv
|
||||
$ref: '@supabase/postgrest-js.PostgrestTransformBuilder.csv'
|
||||
title: csv()
|
||||
|
||||
@@ -7517,9 +7517,9 @@ functions:
|
||||
```
|
||||
- id: listening-to-row-level-changes
|
||||
name: Listen to row level changes
|
||||
description: You can listen to individual rows using the format `{table}:{col}=eq.{val}` - where `{col}` is the column name, and `{val}` is the value which you want to match.
|
||||
description: You can listen to individual rows using the format `{table}:{col}=op.{val}` - where `{col}` is the column name, `{op}` is the filter operator, and `{val}` is the value to match.
|
||||
notes: |
|
||||
- ``eq`` filter works with all database types as under the hood, it's casting both the filter value and the database value to the correct type and then comparing them.
|
||||
- Supported operators: ``eq``, ``neq``, ``lt``, ``lte``, ``gt``, ``gte``, ``in`` (e.g. ``"status=in.(active,pending)"``), ``like``, ``ilike``, ``is`` (e.g. ``"deleted_at=is.null"``), ``match``, ``imatch`` (POSIX regex), ``isdistinct`` (NULL-safe inequality). Prefix any operator with ``not.`` to negate it (e.g. ``"status=not.in.(draft,archived)"``). Combine multiple conditions with commas for an implicit ``AND`` (e.g. ``"amount=gt.100,status=in.(open,pending)"``).
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
@@ -7528,6 +7528,28 @@ functions:
|
||||
.subscribe()
|
||||
)
|
||||
```
|
||||
- id: listening-to-selected-columns
|
||||
name: Listen to selected columns only
|
||||
description: |
|
||||
Use the `select` parameter to receive only specific columns instead of the full row.
|
||||
This reduces payload size, which is especially useful for tables with large `bytea` or `jsonb` columns.
|
||||
code: |
|
||||
```python
|
||||
def handle_record_updated(payload):
|
||||
print("Updated country:", payload)
|
||||
|
||||
response = (
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="countries",
|
||||
select=["id", "name"],
|
||||
callback=handle_record_updated,
|
||||
)
|
||||
.subscribe()
|
||||
)
|
||||
```
|
||||
- id: broadcast-message
|
||||
title: broadcastMessage()
|
||||
description: |
|
||||
|
||||
@@ -116,6 +116,26 @@ functions:
|
||||
Go to [Settings > API > Exposed schemas](/dashboard/project/_/settings/api) and add the schema which you want to expose to the API.
|
||||
|
||||
Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema.
|
||||
- id: initialize-client-with-opentelemetry
|
||||
name: Initialize Client with OpenTelemetry tracing
|
||||
description: |
|
||||
Supabase Swift supports W3C `traceparent` header propagation via an opt-in SwiftPM package trait.
|
||||
When enabled, the active OpenTelemetry span's trace context is automatically injected into every outgoing request across PostgREST, Storage, Auth, Functions, and Realtime — no additional runtime configuration needed.
|
||||
|
||||
Enable the `OpenTelemetry` trait in your `Package.swift` dependency declaration:
|
||||
code: |
|
||||
```swift
|
||||
// Package.swift
|
||||
.package(
|
||||
url: "https://github.com/supabase/supabase-swift.git",
|
||||
from: "2.51.0",
|
||||
traits: ["OpenTelemetry"]
|
||||
)
|
||||
```
|
||||
notes: |
|
||||
- Requires swift-tools-version 6.1 or later (trait support).
|
||||
- The trait is **off by default** — no OTel dependency is linked unless you opt in.
|
||||
- When no span is active, the header is not added; it is always safe to call unconditionally.
|
||||
- id: auth-api
|
||||
title: 'Overview'
|
||||
notes: |
|
||||
@@ -679,6 +699,44 @@ functions:
|
||||
// Open the URL using your preferred method to complete sign-in process.
|
||||
UIApplication.shared.open(url)
|
||||
```
|
||||
- id: sign-in-with-web3
|
||||
title: 'signInWithWeb3()'
|
||||
description: |
|
||||
Signs in a user via a signed Sign in with Ethereum (EIP-4361) or Sign in with Solana message.
|
||||
notes: |
|
||||
- Supports Ethereum (Sign-In with Ethereum) and Solana (Sign-In with Solana), both of which derive from the [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) standard.
|
||||
- Your app is responsible for building the message and obtaining the signature from the user's wallet (e.g. via a WalletConnect session or native wallet SDK) before calling this method.
|
||||
- For `Web3Chain.ethereum` the signature is a `0x`-prefixed hex encoded string. For `Web3Chain.solana` it is a base64 encoded string.
|
||||
examples:
|
||||
- id: sign-in-with-ethereum
|
||||
name: Sign in with an Ethereum wallet
|
||||
isSpotlight: true
|
||||
description: |
|
||||
Sign the EIP-4361 message with the user's Ethereum wallet, then pass the message and its hex encoded signature.
|
||||
code: |
|
||||
```swift
|
||||
let session = try await supabase.auth.signInWithWeb3(
|
||||
credentials: Web3Credentials(
|
||||
chain: .ethereum,
|
||||
message: siweMessage,
|
||||
signature: signatureHex
|
||||
)
|
||||
)
|
||||
```
|
||||
- id: sign-in-with-solana
|
||||
name: Sign in with a Solana wallet
|
||||
description: |
|
||||
Sign the message with the user's Solana wallet, then pass the message and its base64 encoded signature.
|
||||
code: |
|
||||
```swift
|
||||
let session = try await supabase.auth.signInWithWeb3(
|
||||
credentials: Web3Credentials(
|
||||
chain: .solana,
|
||||
message: siwsMessage,
|
||||
signature: signatureBase64
|
||||
)
|
||||
)
|
||||
```
|
||||
- id: sign-in-with-passkey
|
||||
title: 'signInWithPasskey()'
|
||||
notes: |
|
||||
@@ -3711,6 +3769,21 @@ functions:
|
||||
.in("name", values: ["Rio de Janeiro", "San Francisco"])
|
||||
```
|
||||
|
||||
- id: notin
|
||||
title: notIn()
|
||||
description: |
|
||||
Match only rows where `column` is not included in the `values` array. The negation of `in()`.
|
||||
examples:
|
||||
- id: with-select
|
||||
name: With `select()`
|
||||
code: |
|
||||
```swift
|
||||
try await supabase
|
||||
.from("cities")
|
||||
.select("name, country_id")
|
||||
.notIn("name", values: ["Rio de Janeiro", "San Francisco"])
|
||||
```
|
||||
|
||||
- id: contains
|
||||
title: contains()
|
||||
description: |
|
||||
@@ -4275,6 +4348,30 @@ functions:
|
||||
description: |
|
||||
Ensure that the RPC call affects at most 10 rows. Useful for limiting the impact of functions.
|
||||
|
||||
- id: dry-run
|
||||
title: dryRun()
|
||||
description: |
|
||||
Executes the mutation but rolls back the transaction instead of committing it, so no changes are persisted.
|
||||
notes: |
|
||||
- The mutation runs and its result (including side effects such as triggers) is returned in the response, but the transaction is rolled back afterward.
|
||||
- Useful for testing mutations without touching real data.
|
||||
- Requires PostgREST's `db-tx-end` setting to allow client-controlled transaction rollback.
|
||||
examples:
|
||||
- id: with-update
|
||||
name: With `update()`
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```swift
|
||||
try await supabase
|
||||
.from("todos")
|
||||
.update(["done": true])
|
||||
.eq("id", value: 1)
|
||||
.select()
|
||||
.dryRun()
|
||||
.execute()
|
||||
// Row is not actually updated in the database.
|
||||
```
|
||||
|
||||
- id: single
|
||||
title: single()
|
||||
description: |
|
||||
@@ -4316,6 +4413,28 @@ functions:
|
||||
```
|
||||
hideCodeBlock: true
|
||||
isSpotlight: true
|
||||
|
||||
- id: maybe-single
|
||||
title: maybeSingle()
|
||||
description: |
|
||||
Like `single()`, this sets the `application/vnd.pgrst.object+json` accept header so the server enforces a single result. Unlike `single()`, when the query does not match exactly one row the resulting `PGRST116` error is not thrown — the response `value` is `nil` instead.
|
||||
notes: |
|
||||
- PostgREST returns `PGRST116` both when zero rows match and when more than one row matches. `maybeSingle()` returns `nil` for either case; use `single()` for the strict variant that always throws when the query does not match exactly one row.
|
||||
examples:
|
||||
- id: with-select
|
||||
name: With `select()`
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```swift
|
||||
let todo: Todo? = try await supabase
|
||||
.from("todos")
|
||||
.select()
|
||||
.eq("id", value: 42)
|
||||
.maybeSingle()
|
||||
.execute()
|
||||
.value
|
||||
```
|
||||
|
||||
- id: csv
|
||||
title: csv()
|
||||
examples:
|
||||
|
||||
@@ -595,7 +595,6 @@
|
||||
"data/profile/profile-identities-query.ts": 1,
|
||||
"data/profile/profile-unlink-identity-mutation.ts": 2,
|
||||
"data/projects/project-create-mutation.ts": 1,
|
||||
"data/replication/restart-pipeline-helper.ts": 2,
|
||||
"data/replication/rollback-tables-mutation.ts": 1,
|
||||
"data/reports/v2/auth.config.ts": 15,
|
||||
"data/reports/v2/edge-functions.config.ts": 9,
|
||||
|
||||
@@ -156,6 +156,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
|
||||
- [x] A `routes/project/$ref/index.tsx` ← `pages/project/[ref]/index.tsx` (route wraps in `ProjectLayoutWithAuth` itself — see shell delta above)
|
||||
- [x] `routes/project/$ref/merge.tsx` ← `pages/project/[ref]/merge.tsx` (leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
|
||||
- [x] `routes/project/$ref/explorer.tsx` — converted from a leaf into a shell (`ExplorerLayout` + `Outlet`) to host the new `/explorer/notebook/$id` leaf; parent `project/$ref.tsx` shell still provides DefaultLayout.
|
||||
|
||||
### Project shell — `/api/*`
|
||||
|
||||
@@ -321,6 +322,11 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
- [x] A `routes/project/$ref/editor/$id.tsx` ← `pages/project/[ref]/editor/[id].tsx`
|
||||
- [x] A `routes/project/$ref/editor/new.tsx` ← `pages/project/[ref]/editor/new.tsx`
|
||||
|
||||
### Project shell — `/explorer/*`
|
||||
|
||||
- [x] A `routes/project/$ref/explorer/index.tsx` ← `pages/project/[ref]/explorer/index.tsx`
|
||||
- [x] A `routes/project/$ref/explorer/notebook/$id.tsx` ← `pages/project/[ref]/explorer/notebook/[id].tsx`
|
||||
|
||||
### Auth shell — `/sign-in`, `/sign-up`, etc.
|
||||
|
||||
- [x] A `routes/_auth/sign-in.tsx` ← `pages/sign-in.tsx`
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { constants, permissions } from '@supabase/shared-types'
|
||||
|
||||
import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const { OAuthScope } = constants
|
||||
|
||||
type OAuthScopeValue = (typeof OAuthScope)[keyof typeof OAuthScope]
|
||||
|
||||
// Manually extracted from platform mcp controller code. Each tool maps to alternative OAuth-scope
|
||||
// groups with the same semantics as ScopeGroupAlternatives: a token can call the tool when it
|
||||
// holds ALL scopes of at least ONE group (OR between groups, AND within a group). Most tools
|
||||
// assert a single scope; execute_sql asserts database:read OR database:write depending on the
|
||||
// session's read_only mode (mcp.controller.ts), so it carries two alternatives.
|
||||
const MCPToolOAuthScopeMapping: Record<string, OAuthScopeValue[][]> = {
|
||||
apply_migration: [[OAuthScope.DATABASE_WRITE]],
|
||||
// Computes a local confirmation hash without calling the platform — no scope gates it.
|
||||
confirm_cost: [[]],
|
||||
create_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
create_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
delete_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
deploy_edge_function: [[OAuthScope.EDGE_FUNCTIONS_WRITE]],
|
||||
execute_sql: [[OAuthScope.DATABASE_READ], [OAuthScope.DATABASE_WRITE]],
|
||||
generate_typescript_types: [[OAuthScope.DATABASE_READ]],
|
||||
get_advisors: [[OAuthScope.DATABASE_READ]],
|
||||
// Calls getOrganization + listProjects to price a project, so it needs both read scopes.
|
||||
// (The type=branch path returns a constant with no platform call; gating on the project
|
||||
// path's scopes fails closed for branch-only pricing, which is fine for advisory display.)
|
||||
get_cost: [[OAuthScope.ORGANIZATIONS_READ, OAuthScope.PROJECTS_READ]],
|
||||
get_edge_function: [[OAuthScope.EDGE_FUNCTIONS_READ]],
|
||||
get_logs: [[OAuthScope.ANALYTICS_READ]],
|
||||
get_organization: [[OAuthScope.ORGANIZATIONS_READ]],
|
||||
get_project: [[OAuthScope.PROJECTS_READ]],
|
||||
get_project_url: [[OAuthScope.PROJECTS_READ]],
|
||||
get_publishable_keys: [[OAuthScope.SECRETS_READ]],
|
||||
get_storage_config: [[OAuthScope.STORAGE_READ]],
|
||||
list_branches: [[OAuthScope.ENVIRONMENT_READ]],
|
||||
list_edge_functions: [[OAuthScope.EDGE_FUNCTIONS_READ]],
|
||||
// Runs through executeSql with read_only forced true.
|
||||
list_extensions: [[OAuthScope.DATABASE_READ]],
|
||||
list_migrations: [[OAuthScope.DATABASE_READ]],
|
||||
list_organizations: [[OAuthScope.ORGANIZATIONS_READ]],
|
||||
list_projects: [[OAuthScope.PROJECTS_READ]],
|
||||
list_storage_buckets: [[OAuthScope.STORAGE_READ]],
|
||||
// Runs through executeSql with read_only forced true.
|
||||
list_tables: [[OAuthScope.DATABASE_READ]],
|
||||
merge_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
pause_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
rebase_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
reset_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
restore_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
// Queries the public content API — no scope gates it.
|
||||
search_docs: [[]],
|
||||
update_storage_config: [[OAuthScope.STORAGE_WRITE]],
|
||||
}
|
||||
|
||||
type ExtractIds<T> = {
|
||||
[K in keyof T]: {
|
||||
[P in keyof T[K]]: T[K][P] extends { id: infer I } ? I : never
|
||||
}
|
||||
}
|
||||
const FGA_PERMISSIONS = Object.fromEntries(
|
||||
Object.entries(permissions.FgaPermissions).map(([group, permissions]) => [
|
||||
group,
|
||||
Object.fromEntries(Object.entries(permissions).map(([key, { id }]) => [key, id])),
|
||||
])
|
||||
) as ExtractIds<typeof permissions.FgaPermissions>
|
||||
|
||||
// Duplicated from platform (packages/api-core/src/lib/permissions/fga-permissions.ts)
|
||||
// Ideally, this could be exported from @supabase/shared-types
|
||||
export const legacyOauthScopeToFgaPermissionMap: Record<string, string[]> = {
|
||||
'analytics:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_LOGS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_USAGE_READ,
|
||||
],
|
||||
'analytics:write': [],
|
||||
'analytics_config:read': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_READ],
|
||||
'analytics_config:write': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_WRITE],
|
||||
'auth:read': [FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_READ],
|
||||
// Note(Hieu) Auth:write scope grants access to all auth config endpoints.
|
||||
// However, one endpoint requires minimum administrator role, so this oauth scope must also include the FGA PROJECT.ADMIN_WRITE permission
|
||||
'auth:write': [FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_WRITE],
|
||||
'database:read': [
|
||||
FGA_PERMISSIONS.USER.SNIPPETS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADVISORS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_JIT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.SNIPPETS_READ,
|
||||
],
|
||||
'database:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_WRITE,
|
||||
// Note(Hieu): Include database read permission here to align with the project query endpoint.
|
||||
// RLS and FGA guard this endpoint with database read first, then perform an additional check for write queries.
|
||||
// The OAuth guard requires database write directly, which causes a discrepancy error if we don't include read here.
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WEBHOOKS_CONFIG_WRITE,
|
||||
],
|
||||
'domains:read': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_READ,
|
||||
],
|
||||
'domains:write': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_WRITE,
|
||||
],
|
||||
'edge_functions:read': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_READ],
|
||||
'edge_functions:write': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_WRITE],
|
||||
'environment:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_READ,
|
||||
],
|
||||
'environment:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_WRITE,
|
||||
],
|
||||
'organizations:read': [
|
||||
FGA_PERMISSIONS.USER.ORGANIZATIONS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.MEMBERS_READ,
|
||||
],
|
||||
'organizations:write': [],
|
||||
'projects:read': [
|
||||
FGA_PERMISSIONS.USER.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_READ,
|
||||
],
|
||||
'projects:write': [
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_WRITE,
|
||||
],
|
||||
'rest:read': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_READ],
|
||||
'rest:write': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_WRITE],
|
||||
'secrets:read': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_READ,
|
||||
],
|
||||
'secrets:write': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_WRITE,
|
||||
],
|
||||
'storage:read': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_READ,
|
||||
],
|
||||
'storage:write': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_WRITE,
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA
|
||||
* permissions it implies:
|
||||
* {
|
||||
* execute_sql: [["snippets_read", "database_read", ...], ["project_admin_write", ...]]
|
||||
* }
|
||||
* Groups are expanded independently, preserving the OR-of-AND structure. A group is an AND, so it
|
||||
* is kept only when every one of its scopes maps to at least one FGA permission — a partial
|
||||
* expansion would weaken the requirement (e.g. [ORGANIZATIONS_READ, PROJECTS_READ] shrinking to
|
||||
* projects_read alone). A group with any unmapped scope is dropped whole, so the tool stays gated
|
||||
* rather than becoming ungated; an explicitly empty group ([]) is the deliberate ungated marker
|
||||
* and is vacuously kept.
|
||||
* The code is duplicated from platform until we find a better way to share those mappings
|
||||
*/
|
||||
export const expandOAuthScopeGroups = (
|
||||
oAuthScopeGroups: string[][],
|
||||
fgaPermissionMap: Record<string, string[]>
|
||||
): string[][] =>
|
||||
oAuthScopeGroups
|
||||
.filter((group) => group.every((oAuthScope) => (fgaPermissionMap[oAuthScope] ?? []).length > 0))
|
||||
.map((group) => group.flatMap((oAuthScope) => fgaPermissionMap[oAuthScope] ?? []))
|
||||
|
||||
export const MCPToolScopeMappings = Object.entries(MCPToolOAuthScopeMapping).reduce(
|
||||
(acc, [mcpTool, oAuthScopeGroups]) => {
|
||||
acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap)
|
||||
return acc
|
||||
},
|
||||
{} as McpMap
|
||||
)
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
addMCPToolsToScopes,
|
||||
buildAPIPermissionScopeMap,
|
||||
getScopesAndEndpointsForAPI,
|
||||
} from './buildAPIPermissionScopeMap'
|
||||
import { expandOAuthScopeGroups, MCPToolScopeMappings } from './MCPToolScopeMappings'
|
||||
import { type ScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
import { mswServer } from '@/tests/lib/msw'
|
||||
|
||||
describe('getScopesAndEndpointsForAPI', () => {
|
||||
const openAPISpecs = {
|
||||
paths: {
|
||||
'/v1/projects/{ref}/database/migrations': {
|
||||
get: {
|
||||
'x-fga-permissions': [['database_migrations_read']],
|
||||
},
|
||||
post: {
|
||||
'x-fga-permissions': [['database_migrations_write']],
|
||||
},
|
||||
},
|
||||
'/v1/projects/{ref}/database/migrations/{version}': {
|
||||
patch: {
|
||||
'x-fga-permissions': [['database_migrations_write']],
|
||||
},
|
||||
},
|
||||
// Alternative groups: development OR production (real shape of the branching endpoints)
|
||||
'/v1/projects/{ref}/branches': {
|
||||
get: {
|
||||
'x-fga-permissions': [['branching_development_read'], ['branching_production_read']],
|
||||
},
|
||||
},
|
||||
// No annotation -> not part of the map
|
||||
'/v1/projects/available-regions': {
|
||||
get: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
test('indexes scopes and endpoints, preserving alternative permission groups', () => {
|
||||
const permissionScopeMap = getScopesAndEndpointsForAPI(openAPISpecs)
|
||||
|
||||
expect(permissionScopeMap).toEqual({
|
||||
scopes: {
|
||||
database_migrations_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
|
||||
mcp_tools: [],
|
||||
},
|
||||
database_migrations_write: {
|
||||
endpoints: [
|
||||
'POST /v1/projects/{ref}/database/migrations',
|
||||
'PATCH /v1/projects/{ref}/database/migrations/{version}',
|
||||
],
|
||||
mcp_tools: [],
|
||||
},
|
||||
branching_development_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/branches'],
|
||||
mcp_tools: [],
|
||||
},
|
||||
branching_production_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/branches'],
|
||||
mcp_tools: [],
|
||||
},
|
||||
},
|
||||
endpoints: {
|
||||
'GET /v1/projects/{ref}/database/migrations': [['database_migrations_read']],
|
||||
'POST /v1/projects/{ref}/database/migrations': [['database_migrations_write']],
|
||||
'PATCH /v1/projects/{ref}/database/migrations/{version}': [['database_migrations_write']],
|
||||
'GET /v1/projects/{ref}/branches': [
|
||||
['branching_development_read'],
|
||||
['branching_production_read'],
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// An endpoint recorded with zero groups would read as ungated and be reported callable by every
|
||||
// token, so unusable annotations must drop the endpoint instead.
|
||||
test('drops endpoints with empty or unusable permission groups rather than marking them ungated', () => {
|
||||
const { endpoints, scopes } = getScopesAndEndpointsForAPI({
|
||||
paths: {
|
||||
'/v1/empty-groups': { get: { 'x-fga-permissions': [[], undefined] } },
|
||||
'/v1/unannotated': { get: {} },
|
||||
},
|
||||
})
|
||||
|
||||
expect(endpoints).toEqual({})
|
||||
expect(scopes).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMCPToolsToScopes', () => {
|
||||
test('assigns tools to every scope in any of their groups, without duplicates', () => {
|
||||
const scopes: ScopeMap = {
|
||||
database_migrations_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
|
||||
mcp_tools: [],
|
||||
},
|
||||
database_migrations_write: { endpoints: [], mcp_tools: ['apply_migration'] },
|
||||
}
|
||||
|
||||
addMCPToolsToScopes(scopes, {
|
||||
list_migrations: [['database_migrations_read']],
|
||||
apply_migration: [['database_migrations_write'], ['database_migrations_read']],
|
||||
})
|
||||
|
||||
expect(scopes).toEqual({
|
||||
database_migrations_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
|
||||
mcp_tools: ['list_migrations', 'apply_migration'],
|
||||
},
|
||||
database_migrations_write: { endpoints: [], mcp_tools: ['apply_migration'] },
|
||||
})
|
||||
})
|
||||
|
||||
test('initializes scopes that only MCP tools reference', () => {
|
||||
const scopes: ScopeMap = {}
|
||||
|
||||
addMCPToolsToScopes(scopes, { update_storage_config: [['storage_config_write']] })
|
||||
|
||||
expect(scopes.storage_config_write).toEqual({
|
||||
endpoints: [],
|
||||
mcp_tools: ['update_storage_config'],
|
||||
})
|
||||
})
|
||||
|
||||
test('leaves ungated tools out of the scope index', () => {
|
||||
const scopes: ScopeMap = {}
|
||||
|
||||
addMCPToolsToScopes(scopes, { search_docs: [[]] })
|
||||
|
||||
expect(scopes).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCPToolScopeMappings', () => {
|
||||
// Platform gates execute_sql on database:read OR database:write depending on the MCP session's
|
||||
// read_only mode (mcp.controller.ts), so the derived requirement must be two alternatives — a
|
||||
// single conjunctive group would hide the tool from read-only tokens the platform accepts.
|
||||
test('execute_sql derives the database:read bundle OR the database:write bundle', () => {
|
||||
expect(MCPToolScopeMappings.execute_sql).toHaveLength(2)
|
||||
const [readGroup, writeGroup] = MCPToolScopeMappings.execute_sql
|
||||
expect(readGroup).toContain('database_read')
|
||||
expect(readGroup).not.toContain('database_write')
|
||||
expect(writeGroup).toContain('database_write')
|
||||
})
|
||||
|
||||
test('single-scope tools derive a single conjunctive group', () => {
|
||||
expect(MCPToolScopeMappings.apply_migration).toHaveLength(1)
|
||||
expect(MCPToolScopeMappings.apply_migration[0]).toContain('database_write')
|
||||
})
|
||||
|
||||
test('tools without a platform scope gate stay ungated ([[]]), not disabled ([])', () => {
|
||||
expect(MCPToolScopeMappings.confirm_cost).toEqual([[]])
|
||||
expect(MCPToolScopeMappings.search_docs).toEqual([[]])
|
||||
})
|
||||
|
||||
// A group is an AND: expanding only its mapped scopes would weaken the requirement (e.g.
|
||||
// [organizations:read, projects:read] shrinking to projects_read alone) and report the tool
|
||||
// enabled for an incomplete grant.
|
||||
test('a group with any unmapped scope is dropped whole, not partially expanded', () => {
|
||||
const map = { 'projects:read': ['projects_read'] }
|
||||
|
||||
expect(expandOAuthScopeGroups([['organizations:read', 'projects:read']], map)).toEqual([])
|
||||
// Other alternatives and the ungated marker survive the drop untouched.
|
||||
expect(expandOAuthScopeGroups([['organizations:read'], ['projects:read'], []], map)).toEqual([
|
||||
['projects_read'],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
// Guards the OAuth-scope -> legacy-map join: a scope key drifting out of the legacy map must
|
||||
// not inject undefined into the payload (flatMap doesn't flatten it) or silently disable a
|
||||
// gated tool by dropping all its groups.
|
||||
test('every derived group is non-empty strings, and only the ungated tools lack scopes', () => {
|
||||
const ungated = ['confirm_cost', 'search_docs']
|
||||
for (const [tool, groups] of Object.entries(MCPToolScopeMappings)) {
|
||||
expect(groups.length, `${tool} lost all its alternatives`).toBeGreaterThan(0)
|
||||
for (const group of groups) {
|
||||
if (!ungated.includes(tool))
|
||||
expect(group.length, `${tool} has an empty group`).toBeGreaterThan(0)
|
||||
for (const scope of group)
|
||||
expect(typeof scope, `${tool} leaked a non-string scope`).toBe('string')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('get_cost requires both organization and project read bundles together', () => {
|
||||
expect(MCPToolScopeMappings.get_cost).toHaveLength(1)
|
||||
expect(MCPToolScopeMappings.get_cost[0]).toEqual(
|
||||
expect.arrayContaining(['organizations_read', 'projects_read'])
|
||||
)
|
||||
})
|
||||
|
||||
// Drift guard: the exact tool registry of @supabase/mcp-server-supabase@0.8.1, the version the
|
||||
// platform pins. When the platform bumps the MCP server, this list (and the mapping) must be
|
||||
// re-derived from the controller's assertMcpOAuthScope calls.
|
||||
test('covers exactly the tool registry of the deployed MCP server', () => {
|
||||
expect(Object.keys(MCPToolScopeMappings).sort()).toEqual([
|
||||
'apply_migration',
|
||||
'confirm_cost',
|
||||
'create_branch',
|
||||
'create_project',
|
||||
'delete_branch',
|
||||
'deploy_edge_function',
|
||||
'execute_sql',
|
||||
'generate_typescript_types',
|
||||
'get_advisors',
|
||||
'get_cost',
|
||||
'get_edge_function',
|
||||
'get_logs',
|
||||
'get_organization',
|
||||
'get_project',
|
||||
'get_project_url',
|
||||
'get_publishable_keys',
|
||||
'get_storage_config',
|
||||
'list_branches',
|
||||
'list_edge_functions',
|
||||
'list_extensions',
|
||||
'list_migrations',
|
||||
'list_organizations',
|
||||
'list_projects',
|
||||
'list_storage_buckets',
|
||||
'list_tables',
|
||||
'merge_branch',
|
||||
'pause_project',
|
||||
'rebase_branch',
|
||||
'reset_branch',
|
||||
'restore_project',
|
||||
'search_docs',
|
||||
'update_storage_config',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAPIPermissionScopeMap', () => {
|
||||
// vitestSetup starts mswServer with `onUnhandledRequest: 'error'` and resets handlers between
|
||||
// tests, so mocking here keeps that guard instead of replacing global fetch.
|
||||
const stubSpecs = (v1: Record<string, unknown>, v2: Record<string, unknown>) => {
|
||||
mswServer.use(
|
||||
http.get('*/api/v1-json', () => HttpResponse.json(v1)),
|
||||
http.get('*/api/v2-json', () => HttpResponse.json(v2))
|
||||
)
|
||||
}
|
||||
|
||||
test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => {
|
||||
stubSpecs(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}/database/query': {
|
||||
post: { 'x-fga-permissions': [['database_read']] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
paths: {
|
||||
'/v2/projects/{ref}/inspect': { get: { 'x-fga-permissions': [['database_read']] } },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const map = await buildAPIPermissionScopeMap()
|
||||
|
||||
// Both specs contribute an endpoint to the same scope, and neither is duplicated
|
||||
expect(map.scopes.database_read.endpoints).toEqual([
|
||||
'POST /v1/projects/{ref}/database/query',
|
||||
'GET /v2/projects/{ref}/inspect',
|
||||
])
|
||||
// The tools were attached after the merge, so a scope shared by both specs lists each once
|
||||
const tools = map.scopes.database_read.mcp_tools
|
||||
expect(new Set(tools).size).toBe(tools.length)
|
||||
expect(tools).toContain('execute_sql')
|
||||
})
|
||||
|
||||
// Path items may legally carry non-operation members; the specs are fetched live, so a benign
|
||||
// upstream swagger change must not start 500ing this route.
|
||||
test('tolerates path items with non-method OpenAPI members', async () => {
|
||||
stubSpecs(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}': {
|
||||
parameters: [{ name: 'ref', in: 'path', required: true }],
|
||||
summary: 'Project detail',
|
||||
get: { 'x-fga-permissions': [['project_admin_read']] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ paths: {} }
|
||||
)
|
||||
|
||||
const map = await buildAPIPermissionScopeMap()
|
||||
|
||||
expect(map.endpoints['GET /v1/projects/{ref}']).toEqual([['project_admin_read']])
|
||||
expect(Object.keys(map.endpoints)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('returns a copy of the tool mapping so callers cannot corrupt the module singleton', async () => {
|
||||
stubSpecs({ paths: {} }, { paths: {} })
|
||||
|
||||
const map = await buildAPIPermissionScopeMap()
|
||||
expect(map.mcp_tools).toEqual(MCPToolScopeMappings)
|
||||
expect(map.mcp_tools).not.toBe(MCPToolScopeMappings)
|
||||
|
||||
const before = structuredClone(MCPToolScopeMappings.execute_sql)
|
||||
map.mcp_tools.execute_sql.push(['tampered'])
|
||||
expect(MCPToolScopeMappings.execute_sql).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
import z from 'zod'
|
||||
|
||||
// We don't have an OpenAPI that describes mcp tools security requirements so
|
||||
// we have this hard coded file that must be updated when they change
|
||||
import { MCPToolScopeMappings } from './MCPToolScopeMappings'
|
||||
import {
|
||||
EndpointMap,
|
||||
McpMap,
|
||||
PermissionScopeMap,
|
||||
ScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
|
||||
/*
|
||||
* Builds the permissions/endpoint mapping by fetching the OpenAPI specs for our v1 and v2 APIs.
|
||||
* The two specs are indexed together rather than merged afterwards: every v1 path starts with
|
||||
* `/v1/` and every v2 path with `/v2/`, so they can't collide, and one pass de-duplicates a
|
||||
* scope's endpoint list by construction.
|
||||
* @throws InternalServerError when it can't fetch the OpenAPI specs
|
||||
*/
|
||||
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
|
||||
const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([
|
||||
fetchAPIPermissionScope('v1'),
|
||||
fetchAPIPermissionScope('v2'),
|
||||
])
|
||||
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
|
||||
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
|
||||
|
||||
const { scopes, endpoints } = getScopesAndEndpointsForAPI({
|
||||
paths: { ...apiV1Specs.paths, ...apiV2Specs.paths },
|
||||
})
|
||||
addMCPToolsToScopes(scopes, MCPToolScopeMappings)
|
||||
|
||||
return {
|
||||
scopes,
|
||||
endpoints,
|
||||
// Deep copy so a caller mutating the response can't corrupt the module-level mapping, which
|
||||
// outlives every request in a long-running server.
|
||||
mcp_tools: cloneDeep(MCPToolScopeMappings),
|
||||
}
|
||||
}
|
||||
|
||||
// OPEN API specs look like this (only kept the parts we're interested in):
|
||||
// {
|
||||
// "paths": {
|
||||
// "/v1/projects/{ref}/branches": {
|
||||
// "get": {
|
||||
// "x-fga-permissions": [
|
||||
// ["branching_development_read"],
|
||||
// ["branching_production_read"]
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// The extension value is a list of alternative permission groups: a token needs ALL permissions
|
||||
// of at least ONE group (OR between groups, AND within a group). Groups must be preserved verbatim,
|
||||
// not flattened, or OR-alternatives (e.g. development vs production branching) turn into impossible
|
||||
// conjunctions. An endpoint with no usable group is dropped entirely rather than recorded with an
|
||||
// empty requirement, which would read as "ungated" and mark it callable by every token.
|
||||
//
|
||||
// KNOWN DIVERGENCE: annotations are trusted verbatim, and the one on
|
||||
// POST /v1/projects/{ref}/database/query overstates access — the spec publishes
|
||||
// `[[database_read], [database_write]]`, but the route's guard requires database_read outright and
|
||||
// the write group is doc-only (see the execute_sql entry in MCPToolScopeMappings.ts). A token
|
||||
// granted only database_write is therefore shown this endpoint as callable when the guard would
|
||||
// reject it. Studio-created tokens can't hit this (write mode always grants the read scopes too),
|
||||
// so this stays a display inaccuracy for API-created tokens; the fix is correcting the annotation
|
||||
// upstream in the mgmt-api, not special-casing it here.
|
||||
export const getScopesAndEndpointsForAPI = (
|
||||
apiSpecs: z.output<typeof API_SPECS_SCHEMA>
|
||||
): Omit<PermissionScopeMap, 'mcp_tools'> => {
|
||||
const scopes: ScopeMap = {}
|
||||
const endpoints: EndpointMap = {}
|
||||
|
||||
// Loop over each API path to record endpoint permission groups and index scopes -> endpoints
|
||||
Object.entries(apiSpecs.paths).forEach(([path, methods]) => {
|
||||
// Loop over each API path method (get, post, etc.)
|
||||
Object.entries(methods).forEach(([method, methodSpecs]) => {
|
||||
const endpoint = `${method.toUpperCase()} ${path}`
|
||||
const groups = (methodSpecs['x-fga-permissions'] ?? []).filter(
|
||||
(group): group is string[] => Array.isArray(group) && group.length > 0
|
||||
)
|
||||
if (groups.length === 0) return
|
||||
|
||||
endpoints[endpoint] = groups
|
||||
|
||||
groups.flat().forEach((permission) => {
|
||||
// Initialize scope object if needed
|
||||
scopes[permission] = scopes[permission] || { endpoints: [], mcp_tools: [] }
|
||||
|
||||
if (!scopes[permission].endpoints.includes(endpoint)) {
|
||||
scopes[permission].endpoints.push(endpoint)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return { scopes, endpoints }
|
||||
}
|
||||
|
||||
// Assign the mcp tools to their scopes. Scopes referenced only by tools (no endpoint lists them,
|
||||
// e.g. project_snippets_read) are initialized here so they don't vanish from the map. Ungated tools
|
||||
// reference no scope, so they're absent from this index by construction — they belong to no
|
||||
// capability and `getEnabledMcpTools` reports them for every token instead.
|
||||
export const addMCPToolsToScopes = (scopes: ScopeMap, mcp_tools: McpMap) => {
|
||||
Object.entries(mcp_tools).forEach(([mcpTool, toolScopeGroups]) => {
|
||||
toolScopeGroups.flat().forEach((toolScope) => {
|
||||
scopes[toolScope] = scopes[toolScope] || { endpoints: [], mcp_tools: [] }
|
||||
if (!scopes[toolScope].mcp_tools.includes(mcpTool)) {
|
||||
scopes[toolScope].mcp_tools.push(mcpTool)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const NEXT_PUBLIC_API_DOMAIN = process.env.NEXT_PUBLIC_API_DOMAIN || 'https://api.supabase.com'
|
||||
|
||||
const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => {
|
||||
try {
|
||||
const response = await fetch(`${NEXT_PUBLIC_API_DOMAIN}/api/${version}-json`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
}
|
||||
const responseText = await response.text()
|
||||
|
||||
const retryAfter = response.headers.get('Retry-After') ?? undefined
|
||||
throw new InternalServerError(`API v${version} responded with ${response.status}`, {
|
||||
status: response.status,
|
||||
body: responseText,
|
||||
...(retryAfter !== undefined && { retryAfter }),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof InternalServerError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new InternalServerError(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens
|
||||
|
||||
const OPEN_API_PATH_METHOD_SCHEMA = z.object({
|
||||
'x-fga-permissions': z.array(z.string().array().optional()).optional(),
|
||||
})
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'] as const
|
||||
|
||||
// OpenAPI path items may legally carry non-operation members (path-level `parameters`, `summary`,
|
||||
// `description`, `servers`, `$ref`). z.record with an enum key schema rejects unknown keys
|
||||
// outright, which would turn a benign upstream spec change into a 500 for this whole route —
|
||||
// strip them before validating the operations.
|
||||
const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess(
|
||||
(item) =>
|
||||
item !== null && typeof item === 'object'
|
||||
? Object.fromEntries(
|
||||
Object.entries(item).filter(([key]) => (HTTP_METHODS as readonly string[]).includes(key))
|
||||
)
|
||||
: item,
|
||||
z.record(z.enum(HTTP_METHODS), OPEN_API_PATH_METHOD_SCHEMA)
|
||||
)
|
||||
|
||||
const API_SPECS_SCHEMA = z.object({
|
||||
paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA),
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { buildAPIPermissionScopeMap } from './buildAPIPermissionScopeMap'
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
|
||||
/**
|
||||
* Cache on CDN for 5 minutes
|
||||
* Allow serving stale content for 1 minute while revalidating
|
||||
*/
|
||||
const CACHE_CONTROL_SETTINGS = 'public, s-maxage=300, stale-while-revalidate=60'
|
||||
|
||||
export async function OPTIONS() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
Allow: 'GET, HEAD, OPTIONS',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* This route allows us to not add all permissions mappings to the client bundle.
|
||||
* Ideally, this should be provided by the management API, including MCP tools.
|
||||
*/
|
||||
export async function GET() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
|
||||
try {
|
||||
const permissionsScopes = await buildAPIPermissionScopeMap()
|
||||
return NextResponse.json(permissionsScopes, {
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
} catch (error) {
|
||||
let errorCode = 500
|
||||
const headers = new Headers()
|
||||
|
||||
if (error instanceof InternalServerError) {
|
||||
if (typeof error.details?.status === 'number') errorCode = error.details.status
|
||||
if (errorCode === 420) errorCode = 429
|
||||
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
|
||||
headers.set('Retry-After', error.details.retryAfter)
|
||||
}
|
||||
console.error('Failed to fetch scoped token permission scope map: %O', {
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error fetching scoped token permission scope map: %O', error)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Unable to fetch scoped token permission scope map at this time' },
|
||||
{ status: errorCode, headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,23 @@ import { components } from 'api-types'
|
||||
export type ScopedAccessTokenPermission =
|
||||
components['schemas']['CreateScopedAccessTokenBody']['permissions'][number]
|
||||
|
||||
export const NON_EXPIRING_TOKEN_VALUE = 'never'
|
||||
export const CUSTOM_EXPIRY_VALUE = 'custom'
|
||||
|
||||
/** Shared tail for every "this token can no longer be used" message. */
|
||||
export const TOKEN_DENIED_REMEDIATION =
|
||||
'Requests with this token will be denied. Delete this token and create a new one with the resources and permissions you need.'
|
||||
|
||||
/** Warning shown on both entry points that create a classic (account-wide) token. */
|
||||
export const CLASSIC_TOKEN_WARNING = {
|
||||
title: 'Access tokens can be used to control your whole account',
|
||||
description: 'Be careful when sharing your tokens',
|
||||
} as const
|
||||
|
||||
export const EXPIRES_AT_OPTIONS = {
|
||||
hour: { value: 'hour', label: '1 hour' },
|
||||
day: { value: 'day', label: '1 day' },
|
||||
week: { value: 'week', label: '7 days' },
|
||||
month: { value: 'month', label: '30 days' },
|
||||
never: { value: NON_EXPIRING_TOKEN_VALUE, label: 'Never' },
|
||||
custom: { value: CUSTOM_EXPIRY_VALUE, label: 'Custom' },
|
||||
} as const
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { HttpResponse } from 'msw'
|
||||
|
||||
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
/**
|
||||
* Test-only fixtures for the scoped-access-token surfaces.
|
||||
*
|
||||
* The permission-row builders mirror the shape of /platform/profile/permissions rows for each
|
||||
* base role, per the ABAC default_permissions seeds (platform: middleware-db). Roles inherit
|
||||
* lower roles' rows. Keep the rows in lockstep with ROLE_PROBES in AccessToken.roles.ts.
|
||||
*/
|
||||
|
||||
type AccessControlPermission = components['schemas']['AccessControlPermission']
|
||||
type OrganizationResponse = components['schemas']['OrganizationResponse']
|
||||
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
|
||||
|
||||
/** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */
|
||||
export type PermissionRowFixture = Permission & {
|
||||
organization_id: number | null
|
||||
project_ids: number[] | null
|
||||
}
|
||||
|
||||
export const permissionRow = (
|
||||
organization_slug: string,
|
||||
actions: string[],
|
||||
resources: string[],
|
||||
// Org-wide rows serialize as [] or null on the wire (nullable in the API contract; the
|
||||
// view-synthesized admin rows for auth.subject_roles/user_invites are null).
|
||||
project_refs: string[] | null = []
|
||||
): PermissionRowFixture => ({
|
||||
actions: actions as Permission['actions'],
|
||||
condition: null as unknown as Permission['condition'],
|
||||
organization_id: null,
|
||||
organization_slug,
|
||||
project_ids: null,
|
||||
resources,
|
||||
restrictive: false,
|
||||
project_refs,
|
||||
})
|
||||
|
||||
export const memberRows = (slug: string, refs: string[] = []) => [
|
||||
permissionRow(slug, ['read:Read'], ['members', 'organizations', 'auth.subject_roles'], refs),
|
||||
]
|
||||
|
||||
export const readonlyRows = (slug: string, refs: string[] = []) => [
|
||||
...memberRows(slug, refs),
|
||||
permissionRow(slug, ['analytics:Read', 'tenant:Sql:Read:Select'], ['%'], refs),
|
||||
]
|
||||
|
||||
export const developerRows = (slug: string, refs: string[] = []) => [
|
||||
...readonlyRows(slug, refs),
|
||||
permissionRow(
|
||||
slug,
|
||||
['functions:Write', 'tenant:Sql:Admin:Write', 'tenant:Sql:Query'],
|
||||
['%'],
|
||||
refs
|
||||
),
|
||||
]
|
||||
|
||||
export const administratorRows = (slug: string, refs: string[] = []) => [
|
||||
...developerRows(slug, refs),
|
||||
permissionRow(slug, ['write:Create', 'write:Update'], ['projects'], refs),
|
||||
permissionRow(slug, ['billing:Write', 'infra:Execute'], ['%'], refs),
|
||||
]
|
||||
|
||||
export const ownerRows = (slug: string, refs: string[] = []) => [
|
||||
...administratorRows(slug, refs),
|
||||
permissionRow(slug, ['write:Update'], ['organizations'], refs),
|
||||
permissionRow(slug, ['write:Create', 'write:Delete'], ['auth.subject_roles'], refs),
|
||||
]
|
||||
|
||||
export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' }
|
||||
export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' }
|
||||
|
||||
/**
|
||||
* Registers the GET mocks every scoped-token surface fires on mount: one organization
|
||||
* ({@link MOCK_ORG}), one project ({@link MOCK_PROJECT}), and the permission scope map.
|
||||
*/
|
||||
export const mockScopedTokenEnvironment = () => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () =>
|
||||
HttpResponse.json<OrganizationResponse[]>([
|
||||
createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
|
||||
]),
|
||||
})
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects',
|
||||
response: () =>
|
||||
HttpResponse.json<ProjectsResponse>({
|
||||
pagination: { count: 1, limit: 100, offset: 0 },
|
||||
projects: [
|
||||
{
|
||||
...createMockProject({ id: 1, ref: MOCK_PROJECT.ref, name: MOCK_PROJECT.name }),
|
||||
organization_slug: MOCK_ORG.slug,
|
||||
preview_branch_refs: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
// @ts-expect-error Studio API is missing from types
|
||||
path: '/scoped-access-token-permissions',
|
||||
response: () => HttpResponse.json({ scopes: {}, endpoints: {}, mcp_tools: {} }),
|
||||
})
|
||||
}
|
||||
|
||||
export const mockPermissionsApi = (rows: PermissionRowFixture[]) =>
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/profile/permissions',
|
||||
// Permission['condition'] (jsonLogic operator interfaces) has no index signature, so TS won't
|
||||
// match it against the API row's `{ [key: string]: unknown }` — the runtime shape is fine.
|
||||
response: () =>
|
||||
HttpResponse.json<AccessControlPermission[]>(rows as unknown as AccessControlPermission[]),
|
||||
})
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeOverallRisk,
|
||||
countConfigured,
|
||||
getCatalogEntry,
|
||||
scopesToSelection,
|
||||
selectionToScopes,
|
||||
type PermissionSelection,
|
||||
} from './AccessToken.permissions'
|
||||
import {
|
||||
getEnabledEndpoints,
|
||||
getEnabledEndpointsForCapability,
|
||||
getEnabledMcpTools,
|
||||
normalizePermissionScopeMap,
|
||||
type PermissionScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const scopeMap = (partial: Partial<PermissionScopeMap>): PermissionScopeMap => ({
|
||||
scopes: {},
|
||||
endpoints: {},
|
||||
mcp_tools: {},
|
||||
...partial,
|
||||
})
|
||||
|
||||
describe('scopesToSelection', () => {
|
||||
it('round-trips studio-created grants (full read/write sets)', () => {
|
||||
expect(scopesToSelection(['database_read', 'database_write'])).toEqual({
|
||||
'project:database': 'readwrite',
|
||||
})
|
||||
expect(scopesToSelection(['database_read'])).toEqual({ 'project:database': 'read' })
|
||||
expect(scopesToSelection([])).toEqual({})
|
||||
})
|
||||
|
||||
// API-created tokens can hold partial scope sets; the derived mode is an upper bound so the
|
||||
// review UI never claims 'Minimal — no capabilities' for a token with real authority.
|
||||
it('never drops partial grants from API-created tokens', () => {
|
||||
// A lone create scope (one of the entry's three write scopes) still surfaces as readwrite.
|
||||
expect(scopesToSelection(['branching_development_create'])).toEqual({
|
||||
'project:branching_development': 'readwrite',
|
||||
})
|
||||
// A write grant without the read scopes still surfaces (upper-bound readwrite).
|
||||
expect(scopesToSelection(['project_admin_write'])).toEqual({ 'project:admin': 'readwrite' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizePermissionScopeMap', () => {
|
||||
// A stale CDN entry can serve the pre-groups payload (flat conjunctive string[] per endpoint)
|
||||
// to a client whose evaluators expect string[][]; without normalization group.every crashes.
|
||||
it('interprets a stale flat payload as single conjunctive groups', () => {
|
||||
const normalized = normalizePermissionScopeMap({
|
||||
scopes: {},
|
||||
endpoints: {
|
||||
'GET /v1/projects': ['projects_read', 'organization_projects_read'],
|
||||
},
|
||||
mcp_tools: { execute_sql: ['database_read', 'database_write'] },
|
||||
})
|
||||
|
||||
expect(normalized.endpoints['GET /v1/projects']).toEqual([
|
||||
['projects_read', 'organization_projects_read'],
|
||||
])
|
||||
expect(normalized.mcp_tools.execute_sql).toEqual([['database_read', 'database_write']])
|
||||
// The normalized shape evaluates without throwing
|
||||
expect(
|
||||
getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap: normalized })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('passes the current grouped payload through unchanged', () => {
|
||||
const grouped: PermissionScopeMap = {
|
||||
scopes: {},
|
||||
endpoints: { 'GET /v1/branches': [['branching_development_read']] },
|
||||
mcp_tools: { search_docs: [[]], broken_tool: [] },
|
||||
}
|
||||
|
||||
expect(normalizePermissionScopeMap(grouped)).toEqual(grouped)
|
||||
})
|
||||
|
||||
it('tolerates payloads missing any of the three maps entirely', () => {
|
||||
const normalized = normalizePermissionScopeMap({})
|
||||
|
||||
expect(normalized.scopes).toEqual({})
|
||||
expect(normalized.endpoints).toEqual({})
|
||||
expect(normalized.mcp_tools).toEqual({})
|
||||
})
|
||||
|
||||
it('fails closed (nobody) on values that are not arrays at all', () => {
|
||||
const normalized = normalizePermissionScopeMap({
|
||||
scopes: {},
|
||||
endpoints: { 'GET /v1/projects': null },
|
||||
mcp_tools: { execute_sql: 'database_read' },
|
||||
})
|
||||
|
||||
expect(normalized.endpoints['GET /v1/projects']).toEqual([])
|
||||
expect(normalized.mcp_tools.execute_sql).toEqual([])
|
||||
})
|
||||
|
||||
// response.json() can legally produce any of these (a null body, an error string); the
|
||||
// normalizer is the boundary and must return the fail-closed empty map, not throw.
|
||||
it('fails closed on a top-level payload that is not an object', () => {
|
||||
const empty = { scopes: {}, endpoints: {}, mcp_tools: {} }
|
||||
|
||||
expect(normalizePermissionScopeMap(null)).toEqual(empty)
|
||||
expect(normalizePermissionScopeMap(undefined)).toEqual(empty)
|
||||
expect(normalizePermissionScopeMap('internal server error')).toEqual(empty)
|
||||
expect(normalizePermissionScopeMap([])).toEqual(empty)
|
||||
})
|
||||
|
||||
it('empties a field whose record shape is wrong without discarding the rest', () => {
|
||||
const normalized = normalizePermissionScopeMap({
|
||||
scopes: { database_read: { endpoints: 'not-an-array', mcp_tools: [] } },
|
||||
endpoints: { 'GET /v1/projects': [['projects_read']] },
|
||||
mcp_tools: null,
|
||||
})
|
||||
|
||||
expect(normalized.scopes).toEqual({})
|
||||
expect(normalized.endpoints['GET /v1/projects']).toEqual([['projects_read']])
|
||||
expect(normalized.mcp_tools).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectionToScopes', () => {
|
||||
it('ignores none and returns read scope for read mode', () => {
|
||||
const selection: PermissionSelection = { 'project:database': 'read', 'project:backups': 'none' }
|
||||
expect(selectionToScopes(selection)).toEqual(['database_read'])
|
||||
})
|
||||
|
||||
it('returns read + write scopes for readwrite mode', () => {
|
||||
const selection: PermissionSelection = { 'project:database': 'readwrite' }
|
||||
expect(selectionToScopes(selection).sort()).toEqual(['database_read', 'database_write'])
|
||||
})
|
||||
|
||||
it('dedupes and skips unknown keys', () => {
|
||||
const selection: PermissionSelection = {
|
||||
'project:database': 'read',
|
||||
'not:a-real-key': 'readwrite',
|
||||
}
|
||||
expect(selectionToScopes(selection)).toEqual(['database_read'])
|
||||
})
|
||||
|
||||
it('read-only resources have no write scopes', () => {
|
||||
const advisors = getCatalogEntry('project:advisors')
|
||||
expect(advisors?.writable).toBe(false)
|
||||
expect(selectionToScopes({ 'project:advisors': 'readwrite' })).toEqual(['advisors_read'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeOverallRisk', () => {
|
||||
it('is Minimal with no capabilities', () => {
|
||||
expect(computeOverallRisk({}, 'project').level).toBe('Minimal')
|
||||
})
|
||||
|
||||
it('account-level read-only is still Elevated', () => {
|
||||
const risk = computeOverallRisk({ 'project:advisors': 'read' }, 'account')
|
||||
expect(risk.level).toBe('Elevated')
|
||||
expect(risk.tone).toBe('medium')
|
||||
})
|
||||
|
||||
it('account-level with any write is High', () => {
|
||||
const risk = computeOverallRisk({ 'project:realtime_config': 'readwrite' }, 'account')
|
||||
expect(risk.level).toBe('High')
|
||||
})
|
||||
|
||||
it('project high-risk write is High', () => {
|
||||
expect(computeOverallRisk({ 'project:database': 'readwrite' }, 'project').level).toBe('High')
|
||||
})
|
||||
|
||||
it('project medium write is Medium', () => {
|
||||
expect(computeOverallRisk({ 'project:realtime_config': 'readwrite' }, 'project').level).toBe(
|
||||
'Medium'
|
||||
)
|
||||
})
|
||||
|
||||
it('read-only project is Low', () => {
|
||||
expect(computeOverallRisk({ 'project:database': 'read' }, 'project').level).toBe('Low')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countConfigured', () => {
|
||||
it('counts only non-none modes', () => {
|
||||
expect(countConfigured({ a: 'read', b: 'none', c: 'readwrite' })).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission scope map (group enforcement)', () => {
|
||||
it('enables a multi-scope MCP tool group only when all scopes of the group are granted', () => {
|
||||
// create_project requires org read + org project create together (the handler's FGA checks)
|
||||
const permissionScopeMap = scopeMap({
|
||||
mcp_tools: { create_project: [['organization_admin_read', 'organization_projects_create']] },
|
||||
})
|
||||
expect(
|
||||
getEnabledMcpTools({
|
||||
grantedScopes: ['organization_projects_create'],
|
||||
permissionScopeMap,
|
||||
})
|
||||
).not.toContain('create_project')
|
||||
expect(
|
||||
getEnabledMcpTools({
|
||||
grantedScopes: ['organization_admin_read', 'organization_projects_create'],
|
||||
permissionScopeMap,
|
||||
})
|
||||
).toContain('create_project')
|
||||
})
|
||||
|
||||
it('enables a tool with alternative groups when any single group is fully granted', () => {
|
||||
// execute_sql requires database_read OR database_write, depending on read-only mode
|
||||
const permissionScopeMap = scopeMap({
|
||||
mcp_tools: { execute_sql: [['database_read'], ['database_write']] },
|
||||
})
|
||||
expect(getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })).toContain(
|
||||
'execute_sql'
|
||||
)
|
||||
expect(getEnabledMcpTools({ grantedScopes: ['database_write'], permissionScopeMap })).toContain(
|
||||
'execute_sql'
|
||||
)
|
||||
expect(
|
||||
getEnabledMcpTools({ grantedScopes: ['storage_read'], permissionScopeMap })
|
||||
).not.toContain('execute_sql')
|
||||
})
|
||||
|
||||
it('reports ungated tools (one empty group) as enabled for any token, including one with no scopes', () => {
|
||||
// search_docs hits the public content API and confirm_cost computes a local hash, so no
|
||||
// permission gates either — the review step should say so rather than hide them.
|
||||
const permissionScopeMap = scopeMap({
|
||||
mcp_tools: { search_docs: [[]], confirm_cost: [[]] },
|
||||
})
|
||||
|
||||
expect(getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })).toEqual([
|
||||
'search_docs',
|
||||
'confirm_cost',
|
||||
])
|
||||
expect(getEnabledMcpTools({ grantedScopes: [], permissionScopeMap })).toEqual([
|
||||
'search_docs',
|
||||
'confirm_cost',
|
||||
])
|
||||
})
|
||||
|
||||
it('never enables a tool whose alternatives were all dropped ([])', () => {
|
||||
const permissionScopeMap = scopeMap({ mcp_tools: { broken_tool: [] } })
|
||||
|
||||
expect(
|
||||
getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })
|
||||
).not.toContain('broken_tool')
|
||||
})
|
||||
|
||||
it('lists endpoints when at least one alternative group is fully granted', () => {
|
||||
const endpoints = getEnabledEndpoints({
|
||||
grantedScopes: ['database_read', 'database_write', 'branching_development_read'],
|
||||
permissionScopeMap: scopeMap({
|
||||
endpoints: {
|
||||
'GET /api/valid_read': [['database_read']],
|
||||
'POST /api/valid_write': [['database_write']],
|
||||
'PUT /api/valid_both': [['database_read', 'database_write']],
|
||||
// development OR production: development alone is enough
|
||||
'GET /api/valid_alternative': [
|
||||
['branching_development_read'],
|
||||
['branching_production_read'],
|
||||
],
|
||||
'PUT /api/invalid': [['project_write']],
|
||||
'PUT /api/incomplete': [['database_read', 'project_write']],
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(endpoints).toEqual([
|
||||
{ raw: 'GET /api/valid_read', method: 'GET', path: '/api/valid_read' },
|
||||
{ raw: 'POST /api/valid_write', method: 'POST', path: '/api/valid_write' },
|
||||
{ raw: 'PUT /api/valid_both', method: 'PUT', path: '/api/valid_both' },
|
||||
{ raw: 'GET /api/valid_alternative', method: 'GET', path: '/api/valid_alternative' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEnabledEndpointsForCapability', () => {
|
||||
const rawPaths = (endpoints: ReturnType<typeof getEnabledEndpointsForCapability>) =>
|
||||
endpoints.map(({ raw }) => raw)
|
||||
|
||||
it('attributes an endpoint to each capability whose scope is in a fully-granted group', () => {
|
||||
const permissionScopeMap = scopeMap({
|
||||
endpoints: {
|
||||
'GET /api/branches': [['branching_development_read'], ['branching_production_read']],
|
||||
},
|
||||
})
|
||||
const allGrantedScopes = ['branching_development_read', 'branching_production_read']
|
||||
|
||||
expect(
|
||||
rawPaths(
|
||||
getEnabledEndpointsForCapability({
|
||||
capabilityScopes: ['branching_development_read'],
|
||||
allGrantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
)
|
||||
).toEqual(['GET /api/branches'])
|
||||
expect(
|
||||
rawPaths(
|
||||
getEnabledEndpointsForCapability({
|
||||
capabilityScopes: ['branching_production_read'],
|
||||
allGrantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
)
|
||||
).toEqual(['GET /api/branches'])
|
||||
})
|
||||
|
||||
// The endpoint is callable, but thanks to the development alternative — production granted alone
|
||||
// would not have enabled it, so it must not be listed under the production capability.
|
||||
it('does not attribute an endpoint to a capability whose own group is unsatisfied', () => {
|
||||
const enabled = getEnabledEndpointsForCapability({
|
||||
capabilityScopes: ['branching_production_read'],
|
||||
allGrantedScopes: ['branching_development_read'],
|
||||
permissionScopeMap: scopeMap({
|
||||
endpoints: {
|
||||
'GET /api/branches': [['branching_development_read'], ['branching_production_read']],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(enabled).toEqual([])
|
||||
})
|
||||
|
||||
it('requires every scope of the capability group to be granted', () => {
|
||||
const permissionScopeMap = scopeMap({
|
||||
endpoints: { 'PUT /api/upgrade': [['project_admin_read', 'database_read']] },
|
||||
})
|
||||
|
||||
expect(
|
||||
getEnabledEndpointsForCapability({
|
||||
capabilityScopes: ['database_read'],
|
||||
allGrantedScopes: ['database_read'],
|
||||
permissionScopeMap,
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
rawPaths(
|
||||
getEnabledEndpointsForCapability({
|
||||
capabilityScopes: ['database_read'],
|
||||
allGrantedScopes: ['database_read', 'project_admin_read'],
|
||||
permissionScopeMap,
|
||||
})
|
||||
)
|
||||
).toEqual(['PUT /api/upgrade'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,700 @@
|
||||
import { permissions } from '@supabase/shared-types'
|
||||
|
||||
import type { ScopedAccessTokenPermission } from './AccessToken.constants'
|
||||
|
||||
/**
|
||||
* Data model for the scoped access-token creation flow.
|
||||
*
|
||||
* The real permission scopes come from `@supabase/shared-types` (`FgaPermissions`). Those scopes
|
||||
* carry no category or risk metadata, so this file layers editable presentation data on top:
|
||||
* - PERMISSION_CATEGORIES groups every scope into one of five UI categories.
|
||||
* - RESOURCE_METADATA assigns each resource a display name, description, category and risk.
|
||||
*
|
||||
* TODO(product): the risk levels, reasons and "Allows" copy below are proposed defaults — review
|
||||
* and adjust. Where a resource has no explicit metadata entry we fall back to a heuristic.
|
||||
*/
|
||||
|
||||
const FGA = permissions.FgaPermissions
|
||||
|
||||
export type PermissionMode = 'none' | 'read' | 'readwrite'
|
||||
export type RiskLevel = 'low' | 'medium' | 'high'
|
||||
export type PermissionCategoryKey = 'account' | 'project' | 'database' | 'appsvc' | 'infra'
|
||||
|
||||
export interface PermissionCategory {
|
||||
key: PermissionCategoryKey
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Display order matches the accordion, where every category starts collapsed. */
|
||||
export const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
{
|
||||
key: 'project',
|
||||
name: 'Project',
|
||||
description: 'Core project visibility, settings, and diagnostics.',
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
name: 'Database',
|
||||
description: 'SQL access, migrations, backups, and data operations.',
|
||||
},
|
||||
{
|
||||
key: 'appsvc',
|
||||
name: 'Application Services',
|
||||
description: 'Auth, storage, realtime, edge functions, and service configuration.',
|
||||
},
|
||||
{
|
||||
key: 'infra',
|
||||
name: 'Infrastructure & Delivery',
|
||||
description: 'Branch automation, domains, add-ons, and network.',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
name: 'Account & Organization',
|
||||
description: 'Account-wide and organization-level access that spans projects.',
|
||||
},
|
||||
]
|
||||
|
||||
interface ResourceMeta {
|
||||
category: PermissionCategoryKey
|
||||
name: string
|
||||
description: string
|
||||
risk: RiskLevel
|
||||
riskReason: string
|
||||
allowsRead?: string[]
|
||||
allowsWrite?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-resource presentation metadata, keyed by the derived `scope:resource` key (see
|
||||
* AccessToken.constants → ACCESS_TOKEN_RESOURCES). Every resource returned from FgaPermissions
|
||||
* should have an entry; RESOURCE_METADATA_FALLBACK covers anything that slips through.
|
||||
*/
|
||||
const RESOURCE_METADATA: Record<string, ResourceMeta> = {
|
||||
// --- Account & Organization ---
|
||||
'user:organizations': {
|
||||
category: 'account',
|
||||
name: 'Organizations',
|
||||
description: 'Organizations you belong to.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can create new organizations under your account.',
|
||||
allowsRead: ['List your organizations'],
|
||||
allowsWrite: ['Create organizations'],
|
||||
},
|
||||
'user:projects': {
|
||||
category: 'account',
|
||||
name: 'Projects (account-wide)',
|
||||
description: 'Projects across all your organizations.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-only listing of the projects you can access.',
|
||||
allowsRead: ['List your projects'],
|
||||
},
|
||||
'user:snippets': {
|
||||
category: 'account',
|
||||
name: 'SQL Snippets (account-wide)',
|
||||
description: 'Saved SQL snippets across your account.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-only access to your saved snippets.',
|
||||
allowsRead: ['Read your SQL snippets'],
|
||||
},
|
||||
'organization:admin': {
|
||||
category: 'account',
|
||||
name: 'Organization Settings',
|
||||
description: 'Organization settings and project transfers.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read-write grants elevated access to organization settings and project transfers.',
|
||||
allowsRead: ['Read organization settings'],
|
||||
allowsWrite: ['Manage organization settings', 'Transfer projects'],
|
||||
},
|
||||
'organization:members': {
|
||||
category: 'account',
|
||||
name: 'Organization Members',
|
||||
description: 'Members and roles within the organization.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read-write can add or remove members and change roles across your organization.',
|
||||
allowsRead: ['Read organization members'],
|
||||
allowsWrite: ['Add or remove members', 'Change member roles'],
|
||||
},
|
||||
'organization:projects': {
|
||||
category: 'account',
|
||||
name: 'Organization Projects',
|
||||
description: 'Projects within the organization.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can create new projects in the organization.',
|
||||
allowsRead: ['List organization projects'],
|
||||
allowsWrite: ['Create organization projects'],
|
||||
},
|
||||
|
||||
// --- Project ---
|
||||
'project:admin': {
|
||||
category: 'project',
|
||||
name: 'Project Settings',
|
||||
description: 'Project metadata and settings.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read-write grants elevated access to change project settings and configuration.',
|
||||
allowsRead: ['Read project metadata'],
|
||||
allowsWrite: ['Update project settings'],
|
||||
},
|
||||
'project:action_runs': {
|
||||
category: 'project',
|
||||
name: 'Action Runs',
|
||||
description: 'Project action run status and logs.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can trigger action runs that execute project workflows.',
|
||||
allowsRead: ['Read action run status', 'Read run logs'],
|
||||
allowsWrite: ['Trigger action runs'],
|
||||
},
|
||||
'project:advisors': {
|
||||
category: 'project',
|
||||
name: 'Advisors',
|
||||
description: 'Security and performance advisor results.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-only access to advisor findings — no changes possible.',
|
||||
allowsRead: ['Read security advisors', 'Read performance advisors'],
|
||||
},
|
||||
'project:analytics_logs': {
|
||||
category: 'project',
|
||||
name: 'Logs',
|
||||
description: 'Operational logs and log analytics.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-only access to project logs.',
|
||||
allowsRead: ['Read project logs'],
|
||||
},
|
||||
'project:analytics_usage': {
|
||||
category: 'project',
|
||||
name: 'Usage Analytics',
|
||||
description: 'Project usage and analytics data.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-only access to usage analytics.',
|
||||
allowsRead: ['Read usage analytics'],
|
||||
},
|
||||
'project:snippets': {
|
||||
category: 'project',
|
||||
name: 'SQL Snippets',
|
||||
description: 'Saved SQL snippets for the project.',
|
||||
risk: 'low',
|
||||
riskReason: 'Read-write can create and edit saved SQL snippets.',
|
||||
allowsRead: ['Read project SQL snippets'],
|
||||
allowsWrite: ['Manage project SQL snippets'],
|
||||
},
|
||||
|
||||
// --- Database ---
|
||||
'project:database': {
|
||||
category: 'database',
|
||||
name: 'Database',
|
||||
description: 'Database access and data operations.',
|
||||
risk: 'high',
|
||||
riskReason:
|
||||
'Read-write lets this token run arbitrary SQL, so it can modify or delete any data in your database.',
|
||||
allowsRead: ['Read tables and schema', 'Run read-only queries'],
|
||||
allowsWrite: ['Run arbitrary SQL'],
|
||||
},
|
||||
'project:database_migrations': {
|
||||
category: 'database',
|
||||
name: 'Migrations',
|
||||
description: 'Database migration history and application.',
|
||||
risk: 'high',
|
||||
riskReason:
|
||||
'Read-write can apply schema changes that alter or drop tables across your database.',
|
||||
allowsRead: ['Read migration history'],
|
||||
allowsWrite: ['Apply migrations'],
|
||||
},
|
||||
'project:backups': {
|
||||
category: 'database',
|
||||
name: 'Backups',
|
||||
description: 'Database backups, restore points, and restore.',
|
||||
risk: 'high',
|
||||
riskReason:
|
||||
'Read-write can trigger restores that overwrite current data with an earlier snapshot.',
|
||||
allowsRead: ['Read backups and restore points'],
|
||||
allowsWrite: ['Trigger restores'],
|
||||
},
|
||||
'project:database_config': {
|
||||
category: 'database',
|
||||
name: 'Database Config',
|
||||
description: 'Database configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change database configuration.',
|
||||
allowsRead: ['Read database configuration'],
|
||||
allowsWrite: ['Update database configuration'],
|
||||
},
|
||||
'project:database_jit': {
|
||||
category: 'database',
|
||||
name: 'Database JIT',
|
||||
description: 'Just-in-time database access settings.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change just-in-time database access settings.',
|
||||
allowsRead: ['Read JIT settings'],
|
||||
allowsWrite: ['Manage JIT settings'],
|
||||
},
|
||||
'project:database_pooling_config': {
|
||||
category: 'database',
|
||||
name: 'Connection Pooling',
|
||||
description: 'Database connection pooling.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change connection pooling behavior.',
|
||||
allowsRead: ['Read pooling configuration'],
|
||||
allowsWrite: ['Update pooling configuration'],
|
||||
},
|
||||
'project:database_readonly_config': {
|
||||
category: 'database',
|
||||
name: 'Read-only Mode',
|
||||
description: 'Database read-only mode.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can toggle the database into or out of read-only mode.',
|
||||
allowsRead: ['Read read-only mode status'],
|
||||
allowsWrite: ['Toggle read-only mode'],
|
||||
},
|
||||
'project:database_ssl_config': {
|
||||
category: 'database',
|
||||
name: 'SSL Enforcement',
|
||||
description: 'Database SSL configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change SSL enforcement for database connections.',
|
||||
allowsRead: ['Read SSL configuration'],
|
||||
allowsWrite: ['Manage SSL enforcement'],
|
||||
},
|
||||
'project:database_webhooks_config': {
|
||||
category: 'database',
|
||||
name: 'Database Webhooks',
|
||||
description: 'Webhooks triggered from the database.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change database webhook configuration.',
|
||||
allowsRead: ['Read webhook configuration'],
|
||||
allowsWrite: ['Manage database webhooks'],
|
||||
},
|
||||
'project:database_network_bans': {
|
||||
category: 'database',
|
||||
name: 'Network Bans',
|
||||
description: 'Banned IPs for the database.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can ban or unban IP addresses from reaching the database.',
|
||||
allowsRead: ['Read banned IPs'],
|
||||
allowsWrite: ['Manage banned IPs'],
|
||||
},
|
||||
'project:database_network_restrictions': {
|
||||
category: 'database',
|
||||
name: 'Network Restrictions',
|
||||
description: 'Network restrictions for the database.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read-write can change which networks are allowed to reach the database.',
|
||||
allowsRead: ['Read network restrictions'],
|
||||
allowsWrite: ['Manage network restrictions'],
|
||||
},
|
||||
|
||||
// --- Application Services ---
|
||||
'project:auth_config': {
|
||||
category: 'appsvc',
|
||||
name: 'Auth Config',
|
||||
description: 'Authentication provider and settings.',
|
||||
risk: 'high',
|
||||
riskReason:
|
||||
'Read-write can change authentication providers and settings, affecting how users sign in.',
|
||||
allowsRead: ['Read auth configuration'],
|
||||
allowsWrite: ['Update auth providers and settings'],
|
||||
},
|
||||
'project:auth_signing_keys': {
|
||||
category: 'appsvc',
|
||||
name: 'Auth Signing Keys',
|
||||
description: 'Authentication signing keys.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read-write can rotate signing keys, invalidating existing sessions and tokens.',
|
||||
allowsRead: ['Read signing keys'],
|
||||
allowsWrite: ['Manage signing keys'],
|
||||
},
|
||||
'project:api_gateway_keys': {
|
||||
category: 'appsvc',
|
||||
name: 'API Keys',
|
||||
description: 'Project API keys.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read exposes API keys; read-write grants elevated access to create new keys.',
|
||||
allowsRead: ['Read project API keys'],
|
||||
allowsWrite: ['Create and revoke API keys'],
|
||||
},
|
||||
'project:edge_functions': {
|
||||
category: 'appsvc',
|
||||
name: 'Edge Functions',
|
||||
description: 'Edge functions.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can deploy or delete edge functions.',
|
||||
allowsRead: ['Read edge functions'],
|
||||
allowsWrite: ['Deploy and delete edge functions'],
|
||||
},
|
||||
'project:edge_functions_secrets': {
|
||||
category: 'appsvc',
|
||||
name: 'Edge Function Secrets',
|
||||
description: 'Secrets available to edge functions.',
|
||||
risk: 'high',
|
||||
riskReason: 'Read exposes function secrets; read-write can set new secret values.',
|
||||
allowsRead: ['Read edge function secrets'],
|
||||
allowsWrite: ['Set edge function secrets'],
|
||||
},
|
||||
'project:realtime_config': {
|
||||
category: 'appsvc',
|
||||
name: 'Realtime Config',
|
||||
description: 'Realtime configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change realtime settings and shut down active connections.',
|
||||
allowsRead: ['Read realtime configuration'],
|
||||
allowsWrite: ['Update realtime settings'],
|
||||
},
|
||||
'project:storage': {
|
||||
category: 'appsvc',
|
||||
name: 'Storage',
|
||||
description: 'File storage buckets and objects.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can modify or delete stored files.',
|
||||
allowsRead: ['Read storage buckets and objects'],
|
||||
allowsWrite: ['Manage storage buckets and objects'],
|
||||
},
|
||||
'project:storage_config': {
|
||||
category: 'appsvc',
|
||||
name: 'Storage Config',
|
||||
description: 'Storage bucket configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change storage configuration.',
|
||||
allowsRead: ['Read storage configuration'],
|
||||
allowsWrite: ['Update storage configuration'],
|
||||
},
|
||||
'project:data_api_config': {
|
||||
category: 'appsvc',
|
||||
name: 'Data API Config',
|
||||
description: 'PostgREST behavior and settings.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change how the auto-generated Data API behaves.',
|
||||
allowsRead: ['Read Data API configuration'],
|
||||
allowsWrite: ['Update Data API configuration'],
|
||||
},
|
||||
|
||||
// --- Infrastructure & Delivery ---
|
||||
'project:branching_development': {
|
||||
category: 'infra',
|
||||
name: 'Development Branches',
|
||||
description: 'Development branch automation.',
|
||||
risk: 'low',
|
||||
riskReason: 'Branch automation for development workflows — limited blast radius.',
|
||||
allowsRead: ['Read development branches'],
|
||||
allowsWrite: ['Create, update, and delete development branches'],
|
||||
},
|
||||
'project:branching_production': {
|
||||
category: 'infra',
|
||||
name: 'Production Branches',
|
||||
description: 'Production branch automation.',
|
||||
risk: 'high',
|
||||
riskReason:
|
||||
'Read-write grants elevated access to create, merge, or delete production branches.',
|
||||
allowsRead: ['Read production branches'],
|
||||
allowsWrite: ['Create, merge, and delete production branches'],
|
||||
},
|
||||
'project:custom_domain': {
|
||||
category: 'infra',
|
||||
name: 'Custom Domains',
|
||||
description: 'Custom hostnames.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change custom hostnames, affecting how your project is reached.',
|
||||
allowsRead: ['Read custom domain configuration'],
|
||||
allowsWrite: ['Set custom hostnames'],
|
||||
},
|
||||
'project:vanity_subdomain': {
|
||||
category: 'infra',
|
||||
name: 'Vanity Subdomain',
|
||||
description: 'Project vanity subdomain.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change the project vanity subdomain.',
|
||||
allowsRead: ['Read vanity subdomain'],
|
||||
allowsWrite: ['Manage vanity subdomain'],
|
||||
},
|
||||
'project:infra_addons': {
|
||||
category: 'infra',
|
||||
name: 'Add-ons',
|
||||
description: 'Infrastructure add-ons.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can enable or change paid infrastructure add-ons.',
|
||||
allowsRead: ['Read infrastructure add-ons'],
|
||||
allowsWrite: ['Manage infrastructure add-ons'],
|
||||
},
|
||||
'project:infra_disk_config': {
|
||||
category: 'infra',
|
||||
name: 'Disk Config',
|
||||
description: 'Disk configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can change disk size and configuration, which may incur cost.',
|
||||
allowsRead: ['Read disk configuration'],
|
||||
allowsWrite: ['Manage disk configuration'],
|
||||
},
|
||||
'project:read_replicas': {
|
||||
category: 'infra',
|
||||
name: 'Read Replicas',
|
||||
description: 'Read replica configuration.',
|
||||
risk: 'medium',
|
||||
riskReason: 'Read-write can provision or remove read replicas, which may incur cost.',
|
||||
allowsRead: ['Read read-replica configuration'],
|
||||
allowsWrite: ['Manage read replicas'],
|
||||
},
|
||||
}
|
||||
|
||||
const RESOURCE_METADATA_FALLBACK = (
|
||||
resourceKey: string,
|
||||
title: string,
|
||||
hasWrite: boolean
|
||||
): ResourceMeta => ({
|
||||
category: resourceKey.startsWith('project:') ? 'project' : 'account',
|
||||
name: title.replace(/^(Read|Manage|Create|Delete)\s+/i, ''),
|
||||
description: title,
|
||||
risk: hasWrite ? 'medium' : 'low',
|
||||
riskReason: hasWrite
|
||||
? 'Read-write can modify this resource.'
|
||||
: 'Read-only access to this resource.',
|
||||
})
|
||||
|
||||
const PERMISSION_LEVELS = ['user', 'organization', 'project'] as const
|
||||
|
||||
export type PermissionLevel = (typeof PERMISSION_LEVELS)[number]
|
||||
|
||||
/**
|
||||
* Runtime guard for the FGA namespaces: role evaluation branches on the level, so an unrecognized
|
||||
* namespace must fail loudly (at module load, caught by any test importing the catalog) rather
|
||||
* than silently evaluate as project-level.
|
||||
*/
|
||||
const toPermissionLevel = (scope: string): PermissionLevel => {
|
||||
const level = scope.toLowerCase()
|
||||
const match = PERMISSION_LEVELS.find((candidate) => candidate === level)
|
||||
if (match === undefined) throw new Error(`Unknown FGA namespace: ${scope}`)
|
||||
return match
|
||||
}
|
||||
|
||||
export interface PermissionCatalogEntry {
|
||||
/** Derived resource key, e.g. "project:database" */
|
||||
key: string
|
||||
/** Which FGA namespace the resource lives in — decides which role (org vs project) governs it. */
|
||||
level: PermissionLevel
|
||||
category: PermissionCategoryKey
|
||||
name: string
|
||||
description: string
|
||||
risk: RiskLevel
|
||||
riskReason: string
|
||||
allowsRead: string[]
|
||||
allowsWrite: string[]
|
||||
/** Whether a Read-write mode is offered (false => read-only resource). */
|
||||
writable: boolean
|
||||
/** FGA scope ids granted at Read (and above). */
|
||||
readScopes: ScopedAccessTokenPermission[]
|
||||
/** Additional FGA scope ids granted at Read-write (write / create / delete). */
|
||||
writeScopes: ScopedAccessTokenPermission[]
|
||||
}
|
||||
|
||||
const getAction = (key: string): 'read' | 'write' | 'create' | 'delete' => {
|
||||
if (key.endsWith('_WRITE')) return 'write'
|
||||
if (key.endsWith('_CREATE')) return 'create'
|
||||
if (key.endsWith('_DELETE')) return 'delete'
|
||||
return 'read'
|
||||
}
|
||||
|
||||
const getResource = (key: string): string =>
|
||||
key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase()
|
||||
|
||||
/**
|
||||
* Builds the permission catalog from the real FgaPermissions. Each unique `scope:resource` becomes
|
||||
* one row; its read scope maps to Read mode and its write/create/delete scopes to Read-write mode.
|
||||
*/
|
||||
const buildCatalog = (): PermissionCatalogEntry[] => {
|
||||
const byResource = new Map<
|
||||
string,
|
||||
{ level: PermissionLevel; title: string; readScopes: string[]; writeScopes: string[] }
|
||||
>()
|
||||
|
||||
for (const [scope, scopePerms] of Object.entries(FGA)) {
|
||||
const level = toPermissionLevel(scope)
|
||||
for (const [permKey, perm] of Object.entries(scopePerms)) {
|
||||
const resourceKey = `${level}:${getResource(permKey)}`
|
||||
const action = getAction(permKey)
|
||||
if (!byResource.has(resourceKey)) {
|
||||
byResource.set(resourceKey, { level, title: perm.title, readScopes: [], writeScopes: [] })
|
||||
}
|
||||
const entry = byResource.get(resourceKey)!
|
||||
if (action === 'read') entry.readScopes.push(perm.id)
|
||||
else entry.writeScopes.push(perm.id)
|
||||
}
|
||||
}
|
||||
|
||||
const catalog: PermissionCatalogEntry[] = []
|
||||
for (const [key, { level, title, readScopes, writeScopes }] of byResource.entries()) {
|
||||
const meta =
|
||||
RESOURCE_METADATA[key] ?? RESOURCE_METADATA_FALLBACK(key, title, writeScopes.length > 0)
|
||||
catalog.push({
|
||||
key,
|
||||
level,
|
||||
category: meta.category,
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
risk: meta.risk,
|
||||
riskReason: meta.riskReason,
|
||||
allowsRead: meta.allowsRead ?? [`Read ${meta.name.toLowerCase()}`],
|
||||
allowsWrite:
|
||||
meta.allowsWrite ?? (writeScopes.length > 0 ? [`Modify ${meta.name.toLowerCase()}`] : []),
|
||||
writable: writeScopes.length > 0,
|
||||
readScopes: readScopes as ScopedAccessTokenPermission[],
|
||||
writeScopes: writeScopes as ScopedAccessTokenPermission[],
|
||||
})
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
export const PERMISSION_CATALOG = buildCatalog()
|
||||
|
||||
const CATALOG_BY_KEY = new Map(PERMISSION_CATALOG.map((entry) => [entry.key, entry]))
|
||||
|
||||
export const getCatalogEntry = (key: string) => CATALOG_BY_KEY.get(key)
|
||||
|
||||
export interface CategoryWithEntries extends PermissionCategory {
|
||||
entries: PermissionCatalogEntry[]
|
||||
}
|
||||
|
||||
/** Catalog grouped by category, in category display order, dropping empty categories. */
|
||||
export const PERMISSION_CATALOG_BY_CATEGORY: CategoryWithEntries[] = PERMISSION_CATEGORIES.map(
|
||||
(category) => ({
|
||||
...category,
|
||||
entries: PERMISSION_CATALOG.filter((entry) => entry.category === category.key),
|
||||
})
|
||||
).filter((category) => category.entries.length > 0)
|
||||
|
||||
/** Map of resource key -> selected mode. Absent keys are treated as 'none'. */
|
||||
export type PermissionSelection = Record<string, PermissionMode>
|
||||
|
||||
/** FGA scope ids a catalog entry grants at the given mode. */
|
||||
export const getEntryScopes = (
|
||||
entry: PermissionCatalogEntry,
|
||||
mode: PermissionMode
|
||||
): ScopedAccessTokenPermission[] => {
|
||||
if (mode === 'none') return []
|
||||
if (mode === 'readwrite') return [...entry.readScopes, ...entry.writeScopes]
|
||||
return entry.readScopes
|
||||
}
|
||||
|
||||
/** Flattens a selection into the concrete FGA scope ids to send to the API. */
|
||||
export const selectionToScopes = (
|
||||
selection: PermissionSelection
|
||||
): ScopedAccessTokenPermission[] => {
|
||||
const scopes: ScopedAccessTokenPermission[] = []
|
||||
for (const [key, mode] of Object.entries(selection)) {
|
||||
const entry = CATALOG_BY_KEY.get(key)
|
||||
if (!entry) continue
|
||||
scopes.push(...getEntryScopes(entry, mode))
|
||||
}
|
||||
return Array.from(new Set(scopes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses `selectionToScopes`: derives a selection from a token's granted FGA scope ids.
|
||||
*
|
||||
* Tokens created through the Management API can hold arbitrary scope subsets that the
|
||||
* none/read/readwrite modes cannot represent exactly (e.g. a lone branching_development_create).
|
||||
* Any granted scope of an entry marks it at the corresponding mode, so a partial grant is never
|
||||
* dropped — the mode is an upper bound and may name specific operations the token lacks, but it
|
||||
* never understates the token's authority or risk. The endpoint and MCP-tool lists, computed
|
||||
* from the actual granted scopes, remain the precise view.
|
||||
*/
|
||||
export const scopesToSelection = (grantedScopes: string[]): PermissionSelection => {
|
||||
const granted = new Set(grantedScopes)
|
||||
const selection: PermissionSelection = {}
|
||||
for (const entry of PERMISSION_CATALOG) {
|
||||
const hasWrite = entry.writeScopes.some((scope) => granted.has(scope))
|
||||
const hasRead = entry.readScopes.some((scope) => granted.has(scope))
|
||||
if (hasWrite) selection[entry.key] = 'readwrite'
|
||||
else if (hasRead) selection[entry.key] = 'read'
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
export const countConfiguredInCategory = (
|
||||
selection: PermissionSelection,
|
||||
categoryKey: PermissionCategoryKey
|
||||
): number =>
|
||||
PERMISSION_CATALOG.filter(
|
||||
(entry) => entry.category === categoryKey && (selection[entry.key] ?? 'none') !== 'none'
|
||||
).length
|
||||
|
||||
export const countConfigured = (selection: PermissionSelection): number =>
|
||||
Object.values(selection).filter((mode) => mode !== 'none').length
|
||||
|
||||
export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
|
||||
low: 'Low risk',
|
||||
medium: 'Medium risk',
|
||||
high: 'High risk',
|
||||
}
|
||||
|
||||
export const PERMISSION_MODE_LABEL: Record<PermissionMode, string> = {
|
||||
none: 'None',
|
||||
read: 'Read',
|
||||
readwrite: 'Read-write',
|
||||
}
|
||||
|
||||
export const RISK_DOT_CLASS: Record<RiskLevel, string> = {
|
||||
low: 'bg-brand-600',
|
||||
medium: 'bg-warning-600',
|
||||
high: 'bg-destructive-600',
|
||||
}
|
||||
|
||||
export type ResourceAccessMode = 'project' | 'organization' | 'account'
|
||||
|
||||
export interface OverallRisk {
|
||||
/** Minimal | Low | Medium | Elevated | High */
|
||||
level: string
|
||||
text: string
|
||||
tone: 'default' | 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
export const RISK_TONE_VARIANT: Record<
|
||||
OverallRisk['tone'],
|
||||
'default' | 'success' | 'warning' | 'destructive'
|
||||
> = {
|
||||
default: 'default',
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'destructive',
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the overall token risk from the selected capabilities and the resource-access breadth.
|
||||
* Account-level tokens are never below "Elevated", even when read-only.
|
||||
*/
|
||||
export const computeOverallRisk = (
|
||||
selection: PermissionSelection,
|
||||
resourceAccess: ResourceAccessMode
|
||||
): OverallRisk => {
|
||||
const active = Object.entries(selection).filter(([, mode]) => mode !== 'none')
|
||||
if (active.length === 0) {
|
||||
return { level: 'Minimal', text: 'Minimal — No capabilities', tone: 'default' }
|
||||
}
|
||||
|
||||
const anyWrite = active.some(([, mode]) => mode === 'readwrite')
|
||||
const anyHighWrite = active.some(
|
||||
([key, mode]) => mode === 'readwrite' && CATALOG_BY_KEY.get(key)?.risk === 'high'
|
||||
)
|
||||
|
||||
const scopeWord =
|
||||
resourceAccess === 'account'
|
||||
? 'Account-wide'
|
||||
: resourceAccess === 'organization'
|
||||
? 'Organization-wide'
|
||||
: 'Single-project'
|
||||
const accessWord = anyWrite ? 'read-write' : 'read-only'
|
||||
|
||||
let level: string
|
||||
let tone: OverallRisk['tone']
|
||||
if (resourceAccess === 'account') {
|
||||
level = anyWrite ? 'High' : 'Elevated'
|
||||
tone = anyWrite ? 'high' : 'medium'
|
||||
} else if (anyHighWrite) {
|
||||
level = 'High'
|
||||
tone = 'high'
|
||||
} else if (anyWrite) {
|
||||
level = 'Medium'
|
||||
tone = 'medium'
|
||||
} else {
|
||||
level = 'Low'
|
||||
tone = 'low'
|
||||
}
|
||||
|
||||
return { level, text: `${level} — ${scopeWord} ${accessWord} access`, tone }
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { permissions } from '@supabase/shared-types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
administratorRows,
|
||||
developerRows,
|
||||
memberRows,
|
||||
ownerRows,
|
||||
readonlyRows,
|
||||
permissionRow as row,
|
||||
} from './AccessToken.fixtures'
|
||||
import { getCatalogEntry, type PermissionSelection } from './AccessToken.permissions'
|
||||
import {
|
||||
applySelectionToRoleContext,
|
||||
computeTokenRoleContext,
|
||||
estimateRoleLevel,
|
||||
FGA_SCOPE_MINIMUM_ROLE,
|
||||
getIsProjectScopedOnly,
|
||||
requiredRoleForEntry,
|
||||
type TokenRoleContextArgs,
|
||||
} from './AccessToken.roles'
|
||||
|
||||
type EvaluateTokenAccessArgs = TokenRoleContextArgs & { selection: PermissionSelection }
|
||||
|
||||
/**
|
||||
* Composes the two production entry points the way a consumer should: resolve the (expensive)
|
||||
* role context once from its inputs, then apply the (cheap) selection to it on every change.
|
||||
*/
|
||||
const evaluateTokenAccess = ({ selection, ...contextArgs }: EvaluateTokenAccessArgs) =>
|
||||
applySelectionToRoleContext(computeTokenRoleContext(contextArgs), selection)
|
||||
|
||||
const ORG = { slug: 'acme' }
|
||||
const OTHER_ORG = { slug: 'globex' }
|
||||
const PROJECT = { ref: 'abcdefghij1234567890', organization_slug: 'acme' }
|
||||
const OTHER_PROJECT = { ref: 'klmnopqrst1234567890', organization_slug: 'acme' }
|
||||
|
||||
const baseArgs: Omit<EvaluateTokenAccessArgs, 'permissions'> = {
|
||||
selection: {},
|
||||
resourceAccess: 'organization',
|
||||
organizationSlugs: [ORG.slug],
|
||||
projectRefs: [],
|
||||
organizations: [ORG, OTHER_ORG],
|
||||
projects: [PROJECT, OTHER_PROJECT],
|
||||
}
|
||||
|
||||
describe('FGA_SCOPE_MINIMUM_ROLE', () => {
|
||||
it('covers exactly the scope ids published in @supabase/shared-types', () => {
|
||||
const publishedIds = Object.values(permissions.FgaPermissions)
|
||||
.flatMap((group) => Object.values(group))
|
||||
.map((permission) => permission.id)
|
||||
.sort()
|
||||
const mappedIds = Object.keys(FGA_SCOPE_MINIMUM_ROLE).sort()
|
||||
// If this fails, a scope was added/removed upstream: re-transcribe the role unions from the
|
||||
// OpenFGA model (platform: openfga/model/supabase.fga) into FGA_SCOPE_MINIMUM_ROLE.
|
||||
expect(mappedIds).toEqual(publishedIds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateRoleLevel', () => {
|
||||
it('identifies each base role from its permission rows', () => {
|
||||
expect(estimateRoleLevel(ownerRows(ORG.slug), ORG.slug)).toBe('owner')
|
||||
expect(estimateRoleLevel(administratorRows(ORG.slug), ORG.slug)).toBe('administrator')
|
||||
expect(estimateRoleLevel(developerRows(ORG.slug), ORG.slug)).toBe('developer')
|
||||
expect(estimateRoleLevel(readonlyRows(ORG.slug), ORG.slug)).toBe('readonly')
|
||||
expect(estimateRoleLevel(memberRows(ORG.slug), ORG.slug)).toBe('member')
|
||||
expect(estimateRoleLevel([], ORG.slug)).toBe('none')
|
||||
})
|
||||
|
||||
it('scopes the estimate to the queried organization', () => {
|
||||
const rows = [...ownerRows(ORG.slug), ...readonlyRows(OTHER_ORG.slug)]
|
||||
expect(estimateRoleLevel(rows, ORG.slug)).toBe('owner')
|
||||
expect(estimateRoleLevel(rows, OTHER_ORG.slug)).toBe('readonly')
|
||||
})
|
||||
|
||||
it('resolves project-scoped roles only for their projects', () => {
|
||||
const rows = developerRows(ORG.slug, [PROJECT.ref])
|
||||
expect(estimateRoleLevel(rows, ORG.slug, PROJECT.ref)).toBe('developer')
|
||||
// Org-level (no project) the same user is only a member.
|
||||
expect(estimateRoleLevel(rows, ORG.slug)).toBe('member')
|
||||
})
|
||||
})
|
||||
|
||||
describe('project-scoped membership helpers', () => {
|
||||
it('detects project-scoped-only membership', () => {
|
||||
expect(getIsProjectScopedOnly(developerRows(ORG.slug, [PROJECT.ref]), ORG.slug)).toBe(true)
|
||||
expect(getIsProjectScopedOnly(developerRows(ORG.slug), ORG.slug)).toBe(false)
|
||||
expect(getIsProjectScopedOnly([], ORG.slug)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats project_refs: null rows as org-wide, in any row order', () => {
|
||||
// The real /platform/profile/permissions response serializes the view-synthesized
|
||||
// Administrator/Owner rows (auth.subject_roles, user_invites) with project_refs: null, and
|
||||
// the response carries no ordering guarantee.
|
||||
const nullRow = row(ORG.slug, ['write:Create', 'write:Delete'], ['auth.subject_roles'])
|
||||
nullRow.project_refs = null
|
||||
|
||||
expect(getIsProjectScopedOnly([nullRow, ...administratorRows(ORG.slug)], ORG.slug)).toBe(false)
|
||||
expect(getIsProjectScopedOnly([...administratorRows(ORG.slug), nullRow], ORG.slug)).toBe(false)
|
||||
// A lone null row is org-wide too, not project-scoped.
|
||||
expect(getIsProjectScopedOnly([nullRow], ORG.slug)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiredRoleForEntry', () => {
|
||||
it('maps read and readwrite modes to the FGA role unions', () => {
|
||||
const database = getCatalogEntry('project:database')!
|
||||
expect(requiredRoleForEntry(database, 'read')).toBe('readonly')
|
||||
expect(requiredRoleForEntry(database, 'readwrite')).toBe('developer')
|
||||
|
||||
const members = getCatalogEntry('organization:members')!
|
||||
expect(requiredRoleForEntry(members, 'read')).toBe('readonly')
|
||||
expect(requiredRoleForEntry(members, 'readwrite')).toBe('administrator')
|
||||
|
||||
const orgAdmin = getCatalogEntry('organization:admin')!
|
||||
expect(requiredRoleForEntry(orgAdmin, 'readwrite')).toBe('owner')
|
||||
})
|
||||
|
||||
it('takes the strictest scope when readwrite spans multiple write scopes', () => {
|
||||
// branching_production_write is developer, but create/delete require administrator.
|
||||
const branching = getCatalogEntry('project:branching_production')!
|
||||
expect(requiredRoleForEntry(branching, 'readwrite')).toBe('administrator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateTokenAccess', () => {
|
||||
it('is unknown while permissions are loading', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: undefined,
|
||||
})
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.exceedingEntryKeys).toEqual([])
|
||||
expect(result.entries['project:database'].status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('normalizes effectiveSelection on every path, not just the evaluated one', () => {
|
||||
const selection: PermissionSelection = {
|
||||
'project:database': 'readwrite',
|
||||
'project:backups': 'none',
|
||||
'not:a-real-key': 'read',
|
||||
}
|
||||
const expected = { 'project:database': 'readwrite' }
|
||||
|
||||
// Unknown path (permissions still loading)
|
||||
expect(
|
||||
evaluateTokenAccess({ ...baseArgs, selection, permissions: undefined }).effectiveSelection
|
||||
).toEqual(expected)
|
||||
// Account path (selection tracks the owner by definition)
|
||||
expect(
|
||||
evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection,
|
||||
resourceAccess: 'account',
|
||||
organizationSlugs: [],
|
||||
permissions: ownerRows(ORG.slug),
|
||||
}).effectiveSelection
|
||||
).toEqual(expected)
|
||||
// Evaluated path
|
||||
expect(
|
||||
evaluateTokenAccess({ ...baseArgs, selection, permissions: ownerRows(ORG.slug) })
|
||||
.effectiveSelection
|
||||
).toEqual(expected)
|
||||
})
|
||||
|
||||
it('passes everything the user’s role covers', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:database': 'readwrite', 'organization:members': 'readwrite' },
|
||||
permissions: administratorRows(ORG.slug),
|
||||
})
|
||||
expect(result.status).toBe('evaluated')
|
||||
expect(result.exceedingEntryKeys).toEqual([])
|
||||
expect(result.effectiveSelection).toEqual({
|
||||
'project:database': 'readwrite',
|
||||
'organization:members': 'readwrite',
|
||||
})
|
||||
})
|
||||
|
||||
it('flags selections above the user’s role and downgrades the effective mode', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: {
|
||||
'project:database': 'readwrite', // requires developer
|
||||
'project:advisors': 'read', // requires readonly
|
||||
},
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
})
|
||||
expect(result.exceedingEntryKeys).toEqual(['project:database'])
|
||||
expect(result.entries['project:database']).toMatchObject({
|
||||
status: 'exceeds-role',
|
||||
effectiveMode: 'read',
|
||||
requiredRole: 'developer',
|
||||
})
|
||||
expect(result.effectiveSelection).toEqual({
|
||||
'project:database': 'read',
|
||||
'project:advisors': 'read',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops entries whose read mode already exceeds the role', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:api_gateway_keys': 'read' }, // read requires developer
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
})
|
||||
expect(result.entries['project:api_gateway_keys']).toMatchObject({
|
||||
status: 'exceeds-role',
|
||||
effectiveMode: 'none',
|
||||
})
|
||||
expect(result.effectiveSelection).toEqual({})
|
||||
})
|
||||
|
||||
it('uses the weakest role across multiple bound organizations and names the failing ones', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
organizationSlugs: [ORG.slug, OTHER_ORG.slug],
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: [...ownerRows(ORG.slug), ...readonlyRows(OTHER_ORG.slug)],
|
||||
})
|
||||
expect(result.exceedingEntryKeys).toEqual(['project:database'])
|
||||
// Only the org where the role is insufficient is called out.
|
||||
expect(result.entries['project:database'].failingResources).toEqual([
|
||||
{
|
||||
type: 'organization',
|
||||
id: OTHER_ORG.slug,
|
||||
label: OTHER_ORG.slug,
|
||||
role: 'readonly',
|
||||
projectScopedRoles: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('evaluates project mode per selected project for project-scoped members', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'project',
|
||||
projectRefs: [PROJECT.ref],
|
||||
selection: { 'project:database': 'readwrite', 'organization:members': 'read' },
|
||||
permissions: developerRows(ORG.slug, [PROJECT.ref]),
|
||||
})
|
||||
// Developer on the bound project: database readwrite is fine.
|
||||
expect(result.entries['project:database'].status).toBe('ok')
|
||||
// Org-level scopes can never be exercised through a project-scoped token — platform rejects
|
||||
// them outright regardless of the owner's role, so no role evaluation applies.
|
||||
expect(result.entries['organization:members'].status).toBe('unavailable-for-scope')
|
||||
expect(result.entries['organization:members'].effectiveMode).toBe('none')
|
||||
expect(result.entries['organization:members'].failingResources).toEqual([])
|
||||
expect(result.unavailableEntryKeys).toEqual(['organization:members'])
|
||||
expect(result.exceedingEntryKeys).toEqual([])
|
||||
expect(result.effectiveSelection).toEqual({ 'project:database': 'readwrite' })
|
||||
})
|
||||
|
||||
it('honors project-scoped roles for project entries on organization-scoped tokens', () => {
|
||||
// An org member invited as Developer to one project: platform checks the owner's permission
|
||||
// against the project object, so an org-bound token really can database_write there. Only the
|
||||
// projects where the role is insufficient may be reported as failing.
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: developerRows(ORG.slug, [PROJECT.ref]),
|
||||
organizations: [ORG],
|
||||
projects: [PROJECT, OTHER_PROJECT],
|
||||
})
|
||||
expect(result.entries['project:database'].status).toBe('exceeds-role')
|
||||
expect(result.entries['project:database'].failingResources).toEqual([
|
||||
{ type: 'project', id: OTHER_PROJECT.ref, label: OTHER_PROJECT.ref, role: 'member' },
|
||||
])
|
||||
|
||||
// Developer on every project of the org: nothing fails, despite the org role being member.
|
||||
const allProjects = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: developerRows(ORG.slug, [PROJECT.ref, OTHER_PROJECT.ref]),
|
||||
organizations: [ORG],
|
||||
projects: [PROJECT, OTHER_PROJECT],
|
||||
})
|
||||
expect(allProjects.entries['project:database'].status).toBe('ok')
|
||||
expect(allProjects.effectiveSelection).toEqual({ 'project:database': 'readwrite' })
|
||||
})
|
||||
|
||||
it('falls back to the org level for bound orgs with no accessible projects', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
organizations: [ORG],
|
||||
projects: [],
|
||||
})
|
||||
expect(result.entries['project:database'].status).toBe('exceeds-role')
|
||||
expect(result.entries['project:database'].failingResources).toEqual([
|
||||
{
|
||||
type: 'organization',
|
||||
id: ORG.slug,
|
||||
label: ORG.slug,
|
||||
role: 'readonly',
|
||||
projectScopedRoles: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('marks org-level entries unavailable on project-scoped tokens even for org owners', () => {
|
||||
// Platform's getChecks throws for project-scoped tokens on organization endpoints before
|
||||
// any FGA evaluation, so even an org owner's project token can never call them.
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'project',
|
||||
projectRefs: [PROJECT.ref],
|
||||
selection: { 'organization:members': 'readwrite', 'user:organizations': 'read' },
|
||||
permissions: ownerRows(ORG.slug),
|
||||
})
|
||||
expect(result.entries['organization:members'].status).toBe('unavailable-for-scope')
|
||||
// User-level scopes are granted by the token grant alone (token -> scope tuple checks), so
|
||||
// they stay exercisable for any resource binding.
|
||||
expect(result.entries['user:organizations'].status).toBe('ok')
|
||||
expect(result.effectiveSelection).toEqual({ 'user:organizations': 'read' })
|
||||
})
|
||||
|
||||
it('explains org-level failures for members invited only to a project', () => {
|
||||
// Read-only on one project, selecting Organization Settings read-write (requires Owner) on
|
||||
// an organization-scoped token — the failure carries their real per-project role so the UI
|
||||
// can explain the distinction.
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'organization',
|
||||
selection: { 'organization:admin': 'readwrite' },
|
||||
permissions: readonlyRows(ORG.slug, [PROJECT.ref]),
|
||||
organizations: [{ ...ORG, name: 'Acme Corp' }],
|
||||
projects: [{ ...PROJECT, name: 'Acme production' }],
|
||||
})
|
||||
expect(result.entries['organization:admin']).toMatchObject({
|
||||
status: 'exceeds-role',
|
||||
requiredRole: 'owner',
|
||||
failingResources: [
|
||||
{
|
||||
type: 'organization',
|
||||
id: ORG.slug,
|
||||
label: 'Acme Corp',
|
||||
role: 'member',
|
||||
projectScopedRoles: [{ label: 'Acme production', role: 'readonly' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches project-scoped detail even when stray org-level rows exist', () => {
|
||||
// Real permissions data can include org-level rows (e.g. restrictive rules) alongside a
|
||||
// project-scoped role; the per-project detail must still resolve.
|
||||
const strayOrgRow = row(ORG.slug, ['read:Read'], ['notifications'])
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'organization',
|
||||
selection: { 'organization:admin': 'readwrite' },
|
||||
permissions: [...readonlyRows(ORG.slug, [PROJECT.ref]), strayOrgRow],
|
||||
projects: [{ ...PROJECT, name: 'Acme production' }],
|
||||
})
|
||||
expect(result.entries['organization:admin'].failingResources).toEqual([
|
||||
{
|
||||
type: 'organization',
|
||||
id: ORG.slug,
|
||||
label: ORG.slug,
|
||||
role: 'member',
|
||||
projectScopedRoles: [{ label: 'Acme production', role: 'readonly' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not attach project-scoped detail for organization-wide members', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
selection: { 'organization:admin': 'readwrite' },
|
||||
permissions: developerRows(ORG.slug),
|
||||
})
|
||||
expect(result.entries['organization:admin'].failingResources).toEqual([
|
||||
{
|
||||
type: 'organization',
|
||||
id: ORG.slug,
|
||||
label: ORG.slug,
|
||||
role: 'developer',
|
||||
projectScopedRoles: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('labels failing resources with their display names when provided', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'project',
|
||||
projectRefs: [PROJECT.ref],
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
projects: [{ ...PROJECT, name: 'Acme production' }],
|
||||
})
|
||||
expect(result.entries['project:database'].failingResources).toEqual([
|
||||
{ type: 'project', id: PROJECT.ref, label: 'Acme production', role: 'readonly' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports bound resources the user can no longer access', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
organizationSlugs: ['departed-org'],
|
||||
selection: { 'project:database': 'read' },
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
organizations: [ORG],
|
||||
})
|
||||
expect(result.inaccessibleOrgSlugs).toEqual(['departed-org'])
|
||||
expect(result.hasNoAccessibleResource).toBe(true)
|
||||
expect(result.entries['project:database'].status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports partially inaccessible projects while still evaluating the rest', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'project',
|
||||
projectRefs: [PROJECT.ref, 'gone-project-ref-123'],
|
||||
selection: { 'project:database': 'read' },
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
})
|
||||
expect(result.inaccessibleProjectRefs).toEqual(['gone-project-ref-123'])
|
||||
expect(result.hasNoAccessibleResource).toBe(false)
|
||||
expect(result.entries['project:database'].status).toBe('ok')
|
||||
})
|
||||
|
||||
it('is unknown before any resource is selected', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'project',
|
||||
organizationSlugs: [ORG.slug],
|
||||
projectRefs: [],
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: readonlyRows(ORG.slug),
|
||||
})
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.exceedingEntryKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('never flags account-scoped tokens', () => {
|
||||
const result = evaluateTokenAccess({
|
||||
...baseArgs,
|
||||
resourceAccess: 'account',
|
||||
organizationSlugs: [],
|
||||
selection: { 'project:database': 'readwrite' },
|
||||
permissions: memberRows(ORG.slug),
|
||||
})
|
||||
expect(result.exceedingEntryKeys).toEqual([])
|
||||
expect(result.entries['project:database'].status).toBe('ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,640 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import type {
|
||||
PermissionCatalogEntry,
|
||||
PermissionMode,
|
||||
PermissionSelection,
|
||||
ResourceAccessMode,
|
||||
} from './AccessToken.permissions'
|
||||
import { getCatalogEntry, getEntryScopes } from './AccessToken.permissions'
|
||||
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
/**
|
||||
* Client-side estimation of what a scoped token can actually do, given its owner's current role.
|
||||
*
|
||||
* Scoped tokens are enforced server-side as the intersection of the token's granted scopes and
|
||||
* the owner's live role, re-checked on every request. Nothing here gates anything — these helpers
|
||||
* only power advisory UI (warnings in the creation flow, status badges in the token view) so users
|
||||
* aren't surprised when an over-provisioned scope returns 403.
|
||||
*
|
||||
* The minimum-role table below is transcribed from the OpenFGA authorization model
|
||||
* (platform: openfga/model/supabase.fga), where every permission is a union of base roles.
|
||||
* AccessToken.roles.test.ts asserts the table stays in sync with the scope ids in
|
||||
* `@supabase/shared-types` FgaPermissions.
|
||||
*/
|
||||
|
||||
/** Base-role ladder. `member` covers org membership without a base role (e.g. project-scoped users at org level). */
|
||||
export const TOKEN_ROLE_LEVELS = [
|
||||
'none',
|
||||
'member',
|
||||
'readonly',
|
||||
'developer',
|
||||
'administrator',
|
||||
'owner',
|
||||
] as const
|
||||
|
||||
export type TokenRoleLevel = (typeof TOKEN_ROLE_LEVELS)[number]
|
||||
|
||||
const ROLE_RANK = Object.fromEntries(
|
||||
TOKEN_ROLE_LEVELS.map((role, index) => [role, index])
|
||||
) as Record<TokenRoleLevel, number>
|
||||
|
||||
export const TOKEN_ROLE_LABEL: Record<TokenRoleLevel, string> = {
|
||||
none: 'No role',
|
||||
member: 'Member',
|
||||
readonly: 'Read-only',
|
||||
developer: 'Developer',
|
||||
administrator: 'Administrator',
|
||||
owner: 'Owner',
|
||||
}
|
||||
|
||||
const rankOf = (role: TokenRoleLevel) => ROLE_RANK[role]
|
||||
|
||||
const minRole = (a: TokenRoleLevel, b: TokenRoleLevel): TokenRoleLevel =>
|
||||
rankOf(a) <= rankOf(b) ? a : b
|
||||
|
||||
const maxRole = (a: TokenRoleLevel, b: TokenRoleLevel): TokenRoleLevel =>
|
||||
rankOf(a) >= rankOf(b) ? a : b
|
||||
|
||||
/**
|
||||
* Lowest base role that holds each FGA permission scope, transcribed from the role unions in the
|
||||
* OpenFGA model. Keep in the same order as the model for easy diffing.
|
||||
*
|
||||
* The drift-guard test only pins the *key set* (scope ids). The role values have no automated
|
||||
* guard: if a role union changes in the OpenFGA model (e.g. a `_write` scope moves from developer
|
||||
* to administrator), CI stays green and this advisory UI silently gives stale guidance until the
|
||||
* value is re-transcribed here. Reviewers of FGA model changes must update this table in the same
|
||||
* change.
|
||||
*/
|
||||
export const FGA_SCOPE_MINIMUM_ROLE: Record<string, TokenRoleLevel> = {
|
||||
// user — available to any authenticated account, no org role required
|
||||
organizations_read: 'member',
|
||||
organizations_create: 'member',
|
||||
projects_read: 'member',
|
||||
snippets_read: 'member',
|
||||
|
||||
// organization
|
||||
organization_admin_read: 'member',
|
||||
organization_admin_write: 'owner',
|
||||
organization_projects_read: 'member',
|
||||
organization_projects_create: 'administrator',
|
||||
members_read: 'readonly',
|
||||
members_write: 'administrator',
|
||||
platform_webhooks_organization_read: 'member',
|
||||
platform_webhooks_organization_write: 'administrator',
|
||||
|
||||
// project
|
||||
project_admin_read: 'member',
|
||||
project_admin_write: 'administrator',
|
||||
action_runs_read: 'readonly',
|
||||
action_runs_write: 'developer',
|
||||
advisors_read: 'readonly',
|
||||
analytics_config_read: 'developer',
|
||||
analytics_config_write: 'administrator',
|
||||
analytics_logs_read: 'readonly',
|
||||
analytics_usage_read: 'readonly',
|
||||
api_gateway_keys_read: 'developer',
|
||||
api_gateway_keys_write: 'administrator',
|
||||
auth_config_read: 'readonly',
|
||||
auth_config_write: 'developer',
|
||||
auth_signing_keys_read: 'developer',
|
||||
auth_signing_keys_write: 'developer',
|
||||
backups_read: 'readonly',
|
||||
backups_write: 'developer',
|
||||
branching_development_create: 'developer',
|
||||
branching_development_delete: 'developer',
|
||||
branching_development_read: 'readonly',
|
||||
branching_development_write: 'developer',
|
||||
branching_production_create: 'administrator',
|
||||
branching_production_delete: 'administrator',
|
||||
branching_production_read: 'readonly',
|
||||
branching_production_write: 'developer',
|
||||
custom_domain_read: 'readonly',
|
||||
custom_domain_write: 'administrator',
|
||||
data_api_config_read: 'readonly',
|
||||
data_api_config_write: 'administrator',
|
||||
database_read: 'readonly',
|
||||
database_write: 'developer',
|
||||
database_config_read: 'readonly',
|
||||
database_config_write: 'administrator',
|
||||
database_jit_read: 'readonly',
|
||||
database_jit_write: 'administrator',
|
||||
database_network_bans_read: 'readonly',
|
||||
database_network_bans_write: 'administrator',
|
||||
database_network_restrictions_read: 'readonly',
|
||||
database_network_restrictions_write: 'administrator',
|
||||
database_migrations_read: 'readonly',
|
||||
database_migrations_write: 'developer',
|
||||
database_pooling_config_read: 'readonly',
|
||||
database_pooling_config_write: 'administrator',
|
||||
database_readonly_config_read: 'readonly',
|
||||
database_readonly_config_write: 'administrator',
|
||||
database_ssl_config_read: 'readonly',
|
||||
database_ssl_config_write: 'administrator',
|
||||
database_webhooks_config_read: 'readonly',
|
||||
database_webhooks_config_write: 'developer',
|
||||
edge_functions_read: 'readonly',
|
||||
edge_functions_write: 'developer',
|
||||
edge_functions_secrets_read: 'readonly',
|
||||
edge_functions_secrets_write: 'administrator',
|
||||
infra_add_ons_read: 'readonly',
|
||||
infra_add_ons_write: 'administrator',
|
||||
infra_disk_config_read: 'readonly',
|
||||
infra_disk_config_write: 'administrator',
|
||||
infra_read_replicas_read: 'readonly',
|
||||
infra_read_replicas_write: 'administrator',
|
||||
project_snippets_read: 'readonly',
|
||||
project_snippets_write: 'readonly',
|
||||
realtime_config_read: 'readonly',
|
||||
realtime_config_write: 'administrator',
|
||||
storage_read: 'readonly',
|
||||
storage_write: 'developer',
|
||||
storage_config_read: 'readonly',
|
||||
storage_config_write: 'administrator',
|
||||
vanity_subdomain_read: 'administrator',
|
||||
vanity_subdomain_write: 'administrator',
|
||||
platform_webhooks_projects_read: 'member',
|
||||
platform_webhooks_projects_write: 'administrator',
|
||||
}
|
||||
|
||||
/**
|
||||
* ABAC checks that identify the user's base role from their own permission rows (the ungated
|
||||
* /platform/profile/permissions response). Base roles inherit each other's rows
|
||||
* (Owner ⊃ Administrator ⊃ Developer ⊃ Read-only), so the first probe that passes, walking
|
||||
* top-down, is the user's level. Each probe is a permission only that role and above holds.
|
||||
*/
|
||||
const ROLE_PROBES: { role: TokenRoleLevel; action: string; resource: string }[] = [
|
||||
{ role: 'owner', action: PermissionAction.UPDATE, resource: 'organizations' },
|
||||
{ role: 'administrator', action: PermissionAction.CREATE, resource: 'projects' },
|
||||
{ role: 'developer', action: PermissionAction.FUNCTIONS_WRITE, resource: 'functions' },
|
||||
{ role: 'readonly', action: PermissionAction.TENANT_SQL_SELECT, resource: 'sql' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Estimates the user's base role in an organization (or on a specific project, when the user's
|
||||
* access is project-scoped). Custom roles resolve to the nearest base role by capability, which
|
||||
* matches how they behave in the FGA model.
|
||||
*/
|
||||
export const estimateRoleLevel = (
|
||||
permissions: Permission[],
|
||||
organizationSlug: string,
|
||||
projectRef?: string
|
||||
): TokenRoleLevel => {
|
||||
for (const probe of ROLE_PROBES) {
|
||||
if (
|
||||
doPermissionsCheck(
|
||||
permissions,
|
||||
probe.action,
|
||||
probe.resource,
|
||||
undefined,
|
||||
organizationSlug,
|
||||
projectRef
|
||||
)
|
||||
) {
|
||||
return probe.role
|
||||
}
|
||||
}
|
||||
const isMember = permissions.some(
|
||||
(permission) => permission.organization_slug === organizationSlug
|
||||
)
|
||||
return isMember ? 'member' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every permission row the user holds in the org is limited to specific projects.
|
||||
* Org-wide rows arrive as [] or null (the API contract is nullable) — both mean not scoped.
|
||||
*/
|
||||
export const getIsProjectScopedOnly = (
|
||||
permissions: Permission[],
|
||||
organizationSlug: string
|
||||
): boolean => {
|
||||
const orgRows = permissions.filter(
|
||||
(permission) => permission.organization_slug === organizationSlug
|
||||
)
|
||||
if (orgRows.length === 0) return false
|
||||
return orgRows.every(
|
||||
(permission) => Array.isArray(permission.project_refs) && permission.project_refs.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Lowest role that holds every scope in the list. Unknown scope ids assume `owner` (warn rather than promise). */
|
||||
const requiredRoleForScopes = (scopeIds: string[]): TokenRoleLevel => {
|
||||
let required: TokenRoleLevel = 'member'
|
||||
for (const id of scopeIds) {
|
||||
required = maxRole(required, FGA_SCOPE_MINIMUM_ROLE[id] ?? 'owner')
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
/** Lowest role that can exercise a catalog entry at the given mode. */
|
||||
export const requiredRoleForEntry = (
|
||||
entry: PermissionCatalogEntry,
|
||||
mode: PermissionMode
|
||||
): TokenRoleLevel =>
|
||||
mode === 'none' ? 'member' : requiredRoleForScopes(getEntryScopes(entry, mode))
|
||||
|
||||
/**
|
||||
* 'unavailable-for-scope': the entry's permission level can never be exercised through this
|
||||
* token's resource binding, regardless of the owner's role — platform rejects project-scoped
|
||||
* tokens outright on organization endpoints.
|
||||
*/
|
||||
export type EntryAccessStatus = 'ok' | 'exceeds-role' | 'unavailable-for-scope' | 'unknown'
|
||||
|
||||
/** A token-bound resource where the user's current role can't exercise the selected mode. */
|
||||
export interface FailingResource {
|
||||
type: 'organization' | 'project'
|
||||
/** Org slug or project ref — unique, unlike `label`. Use for React keys and grouping. */
|
||||
id: string
|
||||
/** Display name of the org/project, falling back to its slug/ref. */
|
||||
label: string
|
||||
/** The user's current role on that resource. */
|
||||
role: TokenRoleLevel
|
||||
/**
|
||||
* Set when the user has no organization-level role here but does hold roles on specific
|
||||
* projects (they were invited to projects, not the org). Lets the UI say "your role is
|
||||
* Read-only on the project X" instead of an opaque org-level pseudo-role.
|
||||
*/
|
||||
projectScopedRoles?: { label: string; role: TokenRoleLevel }[]
|
||||
}
|
||||
|
||||
export interface EntryAccess {
|
||||
status: EntryAccessStatus
|
||||
/** Highest mode the user's current role can exercise for this entry. */
|
||||
effectiveMode: PermissionMode
|
||||
/** Lowest role that could exercise the selected mode. */
|
||||
requiredRole: TokenRoleLevel
|
||||
/** Resources where the selected mode would be denied (empty unless status is 'exceeds-role'). */
|
||||
failingResources: FailingResource[]
|
||||
}
|
||||
|
||||
export interface TokenAccessEvaluation {
|
||||
/** 'unknown' while the user's permissions are loading (or on self-hosted) — show no warnings. */
|
||||
status: 'unknown' | 'evaluated'
|
||||
/** Token-bound orgs the user can no longer access. */
|
||||
inaccessibleOrgSlugs: string[]
|
||||
/** Token-bound projects the user can no longer access. */
|
||||
inaccessibleProjectRefs: string[]
|
||||
/** True when a resource-scoped token has no bindings left — everything it was bound to was deleted. */
|
||||
hasNoBoundResources: boolean
|
||||
/** True when the token is bound to resources but the user can access none of them. */
|
||||
hasNoAccessibleResource: boolean
|
||||
/** Per selected catalog entry key. */
|
||||
entries: Record<string, EntryAccess>
|
||||
/** Entry keys whose selected mode exceeds the user's current role. */
|
||||
exceedingEntryKeys: string[]
|
||||
/** Entry keys the token's resource binding can never exercise (org entries on project tokens). */
|
||||
unavailableEntryKeys: string[]
|
||||
/**
|
||||
* Selection reduced to what the user's current role can exercise. Always normalized to
|
||||
* catalog-known, non-'none' entries — including on the 'unknown' and account paths, where no
|
||||
* reduction applies.
|
||||
*/
|
||||
effectiveSelection: PermissionSelection
|
||||
}
|
||||
|
||||
export interface TokenRoleContextArgs {
|
||||
resourceAccess: ResourceAccessMode
|
||||
/** Token-bound org slugs (organization mode), or the parent org (project mode, from the form). */
|
||||
organizationSlugs: string[]
|
||||
/** Token-bound project refs (project mode). */
|
||||
projectRefs: string[]
|
||||
/** The user's own ABAC permission rows; undefined while loading. */
|
||||
permissions: Permission[] | undefined
|
||||
/** Organizations the user can currently access. */
|
||||
organizations: { slug: string; name?: string }[]
|
||||
/** Projects the user can currently access. */
|
||||
projects: { ref: string; organization_slug: string; name?: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-independent role resolution for a token's bound resources. Resolving roles walks the
|
||||
* user's full permission list several times, so callers should memoize this on its inputs and
|
||||
* apply (cheap) selection changes via `applySelectionToRoleContext`.
|
||||
*/
|
||||
export interface TokenRoleContext {
|
||||
status: 'unknown' | 'evaluated'
|
||||
resourceAccess: ResourceAccessMode
|
||||
inaccessibleOrgSlugs: string[]
|
||||
inaccessibleProjectRefs: string[]
|
||||
hasNoBoundResources: boolean
|
||||
hasNoAccessibleResource: boolean
|
||||
/** Per bound organization (or parent org in project mode). */
|
||||
orgLevels: FailingResource[]
|
||||
/**
|
||||
* Per bound project in project mode; per accessible project of the bound orgs in organization
|
||||
* mode (platform checks project permissions against the project object, so project-scoped
|
||||
* roles count). Orgs with no accessible projects contribute their org level instead.
|
||||
*/
|
||||
projectLevels: FailingResource[]
|
||||
/** Weakest role across orgLevels / projectLevels. */
|
||||
orgLevel: TokenRoleLevel
|
||||
projectLevel: TokenRoleLevel
|
||||
}
|
||||
|
||||
const UNKNOWN_ENTRY: EntryAccess = {
|
||||
status: 'unknown',
|
||||
effectiveMode: 'none',
|
||||
requiredRole: 'member',
|
||||
failingResources: [],
|
||||
}
|
||||
|
||||
const minOver = (levels: FailingResource[]): TokenRoleLevel =>
|
||||
levels.length === 0
|
||||
? 'none'
|
||||
: levels.reduce<TokenRoleLevel>((lowest, level) => minRole(lowest, level.role), 'owner')
|
||||
|
||||
export const computeTokenRoleContext = ({
|
||||
resourceAccess,
|
||||
organizationSlugs,
|
||||
projectRefs,
|
||||
permissions,
|
||||
organizations,
|
||||
projects,
|
||||
}: TokenRoleContextArgs): TokenRoleContext => {
|
||||
const boundResourceIds =
|
||||
resourceAccess === 'project'
|
||||
? projectRefs
|
||||
: resourceAccess === 'organization'
|
||||
? organizationSlugs
|
||||
: []
|
||||
const hasNoBoundResources = resourceAccess !== 'account' && boundResourceIds.length === 0
|
||||
|
||||
const unknownContext = (status: TokenRoleContext['status']): TokenRoleContext => ({
|
||||
status,
|
||||
resourceAccess,
|
||||
inaccessibleOrgSlugs: [],
|
||||
inaccessibleProjectRefs: [],
|
||||
hasNoBoundResources,
|
||||
hasNoAccessibleResource: false,
|
||||
orgLevels: [],
|
||||
projectLevels: [],
|
||||
orgLevel: 'none',
|
||||
projectLevel: 'none',
|
||||
})
|
||||
|
||||
// Nothing to evaluate while permissions load, or until resources are chosen (mid-form state).
|
||||
if (permissions === undefined || hasNoBoundResources) return unknownContext('unknown')
|
||||
if (resourceAccess === 'account') return unknownContext('evaluated')
|
||||
|
||||
const knownOrgSlugs = new Set(organizations.map((org) => org.slug))
|
||||
const projectsByRef = new Map(projects.map((project) => [project.ref, project]))
|
||||
|
||||
const inaccessibleOrgSlugs = organizationSlugs.filter((slug) => !knownOrgSlugs.has(slug))
|
||||
// Only meaningful in project mode — the form can carry stale projectRefs after a mode switch.
|
||||
const inaccessibleProjectRefs =
|
||||
resourceAccess === 'project' ? projectRefs.filter((ref) => !projectsByRef.has(ref)) : []
|
||||
|
||||
const accessibleOrgSlugs = organizationSlugs.filter((slug) => knownOrgSlugs.has(slug))
|
||||
const accessibleProjects = projectRefs.flatMap((ref) => projectsByRef.get(ref) ?? [])
|
||||
|
||||
const hasNoAccessibleResource =
|
||||
resourceAccess === 'project' ? accessibleProjects.length === 0 : accessibleOrgSlugs.length === 0
|
||||
|
||||
if (hasNoAccessibleResource) {
|
||||
return {
|
||||
...unknownContext('evaluated'),
|
||||
inaccessibleOrgSlugs,
|
||||
inaccessibleProjectRefs,
|
||||
hasNoAccessibleResource,
|
||||
}
|
||||
}
|
||||
|
||||
// Role probes walk every permission row; the same org/project pair is asked for repeatedly
|
||||
// (project levels + project-scoped detail), so resolve each pair once.
|
||||
const roleCache = new Map<string, TokenRoleLevel>()
|
||||
const roleFor = (slug: string, ref?: string): TokenRoleLevel => {
|
||||
const cacheKey = `${slug}|${ref ?? ''}`
|
||||
const cached = roleCache.get(cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
const role = estimateRoleLevel(permissions, slug, ref)
|
||||
roleCache.set(cacheKey, role)
|
||||
return role
|
||||
}
|
||||
|
||||
const organizationsBySlug = new Map(organizations.map((org) => [org.slug, org]))
|
||||
|
||||
// The form passes the parent org even in project mode; the token view may not, so fall back to
|
||||
// the bound projects' parent orgs when no org slug was provided.
|
||||
const orgSlugsForLevels =
|
||||
accessibleOrgSlugs.length > 0
|
||||
? accessibleOrgSlugs
|
||||
: Array.from(new Set(accessibleProjects.map((project) => project.organization_slug)))
|
||||
|
||||
// For members without an organization-level role, resolve their per-project roles so org-level
|
||||
// failures can explain the distinction (invited to projects, not the org). In project mode only
|
||||
// the token-bound projects are relevant; in organization mode (e.g. a token that predates a
|
||||
// role change) look at every project they can access in the org.
|
||||
const getProjectScopedRoles = (
|
||||
slug: string,
|
||||
orgRole: TokenRoleLevel
|
||||
): FailingResource['projectScopedRoles'] => {
|
||||
if (rankOf(orgRole) >= ROLE_RANK.readonly) return undefined
|
||||
const candidates =
|
||||
resourceAccess === 'project'
|
||||
? accessibleProjects.filter((project) => project.organization_slug === slug)
|
||||
: projects.filter((project) => project.organization_slug === slug)
|
||||
const roles = candidates.flatMap((project) => {
|
||||
const role = roleFor(slug, project.ref)
|
||||
if (rankOf(role) < ROLE_RANK.readonly) return []
|
||||
return [{ label: project.name ?? project.ref, role }]
|
||||
})
|
||||
return roles.length > 0 ? roles : undefined
|
||||
}
|
||||
|
||||
const orgLevels: FailingResource[] = orgSlugsForLevels.map((slug) => {
|
||||
const role = roleFor(slug)
|
||||
return {
|
||||
type: 'organization',
|
||||
id: slug,
|
||||
label: organizationsBySlug.get(slug)?.name ?? slug,
|
||||
role,
|
||||
projectScopedRoles: getProjectScopedRoles(slug, role),
|
||||
}
|
||||
})
|
||||
|
||||
const toProjectLevel = (project: { ref: string; organization_slug: string; name?: string }) => ({
|
||||
type: 'project' as const,
|
||||
id: project.ref,
|
||||
label: project.name ?? project.ref,
|
||||
role: roleFor(project.organization_slug, project.ref),
|
||||
})
|
||||
|
||||
// In organization mode the token's scope cascades to every project of the bound orgs, and
|
||||
// platform checks the owner's permission against the project object — so a project-scoped
|
||||
// Developer really can exercise e.g. database_write on their project through an org-bound
|
||||
// token. Evaluate project-level entries per accessible project rather than by the org-level
|
||||
// role, falling back to the org level for orgs with no accessible projects. Future projects
|
||||
// only ever inherit the org-level role; the per-project view can't warn about those.
|
||||
const projectLevels: FailingResource[] =
|
||||
resourceAccess === 'project'
|
||||
? accessibleProjects.map(toProjectLevel)
|
||||
: orgSlugsForLevels.flatMap((slug) => {
|
||||
const orgProjects = projects.filter((project) => project.organization_slug === slug)
|
||||
if (orgProjects.length === 0) return orgLevels.filter((level) => level.id === slug)
|
||||
return orgProjects.map(toProjectLevel)
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'evaluated',
|
||||
resourceAccess,
|
||||
inaccessibleOrgSlugs,
|
||||
inaccessibleProjectRefs,
|
||||
hasNoBoundResources,
|
||||
hasNoAccessibleResource,
|
||||
orgLevels,
|
||||
projectLevels,
|
||||
orgLevel: minOver(orgLevels),
|
||||
projectLevel: minOver(projectLevels),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a scope selection to a resolved role context. Cheap — safe to re-run on every
|
||||
* permission toggle. Account-scoped (legacy/user) tokens track the owner's access by definition,
|
||||
* so every entry evaluates as 'ok' there.
|
||||
*/
|
||||
export const applySelectionToRoleContext = (
|
||||
context: TokenRoleContext,
|
||||
selection: PermissionSelection
|
||||
): TokenAccessEvaluation => {
|
||||
// Every path reports entries and effectiveSelection over the same normalized key set, so
|
||||
// consumers can iterate either without special-casing 'none' modes or unknown catalog keys.
|
||||
const selectedKeys = Object.keys(selection).filter(
|
||||
(key) => selection[key] !== 'none' && getCatalogEntry(key) !== undefined
|
||||
)
|
||||
const normalizedSelection: PermissionSelection = Object.fromEntries(
|
||||
selectedKeys.map((key) => [key, selection[key]])
|
||||
)
|
||||
|
||||
const base = {
|
||||
status: context.status,
|
||||
inaccessibleOrgSlugs: context.inaccessibleOrgSlugs,
|
||||
inaccessibleProjectRefs: context.inaccessibleProjectRefs,
|
||||
hasNoBoundResources: context.hasNoBoundResources,
|
||||
hasNoAccessibleResource: context.hasNoAccessibleResource,
|
||||
exceedingEntryKeys: [] as string[],
|
||||
unavailableEntryKeys: [] as string[],
|
||||
effectiveSelection: normalizedSelection,
|
||||
}
|
||||
|
||||
// Account-scoped (legacy/user) tokens track the owner's access by definition — every entry is
|
||||
// exercisable, so requiredRole/failingResources (only read for 'exceeds-role' entries) stay inert.
|
||||
if (context.status === 'evaluated' && context.resourceAccess === 'account') {
|
||||
return {
|
||||
...base,
|
||||
entries: Object.fromEntries(
|
||||
selectedKeys.map((key): [string, EntryAccess] => [
|
||||
key,
|
||||
{
|
||||
status: 'ok',
|
||||
effectiveMode: selection[key],
|
||||
requiredRole: 'member',
|
||||
failingResources: [],
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (context.status === 'unknown' || context.hasNoAccessibleResource) {
|
||||
return {
|
||||
...base,
|
||||
entries: Object.fromEntries(selectedKeys.map((key) => [key, UNKNOWN_ENTRY])),
|
||||
}
|
||||
}
|
||||
|
||||
const { orgLevel, projectLevel, orgLevels, projectLevels } = context
|
||||
const entries: Record<string, EntryAccess> = {}
|
||||
const exceedingEntryKeys: string[] = []
|
||||
const unavailableEntryKeys: string[] = []
|
||||
const effectiveSelection: PermissionSelection = {}
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
const mode = selection[key]
|
||||
const entry = getCatalogEntry(key)
|
||||
if (!entry) continue
|
||||
|
||||
// Platform rejects project-scoped tokens outright on organization endpoints (getChecks
|
||||
// throws before any FGA evaluation), so the owner's org role is irrelevant there and
|
||||
// role-based evaluation would wrongly report these entries as exercisable. The one
|
||||
// exception — organization_admin_write is additionally enforced on a few project-ref
|
||||
// routes via the model's `from parent_organization` indirection — is deliberately
|
||||
// ignored: this advisory UI fails closed.
|
||||
if (context.resourceAccess === 'project' && entry.level === 'organization') {
|
||||
entries[key] = {
|
||||
status: 'unavailable-for-scope',
|
||||
effectiveMode: 'none',
|
||||
requiredRole: requiredRoleForEntry(entry, mode),
|
||||
failingResources: [],
|
||||
}
|
||||
unavailableEntryKeys.push(key)
|
||||
continue
|
||||
}
|
||||
|
||||
const availableLevel =
|
||||
entry.level === 'user' ? 'owner' : entry.level === 'organization' ? orgLevel : projectLevel
|
||||
|
||||
const requiredRole = requiredRoleForEntry(entry, mode)
|
||||
|
||||
let effectiveMode: PermissionMode = 'none'
|
||||
if (rankOf(availableLevel) >= rankOf(requiredRole)) {
|
||||
effectiveMode = mode
|
||||
} else if (
|
||||
mode === 'readwrite' &&
|
||||
rankOf(availableLevel) >= rankOf(requiredRoleForEntry(entry, 'read'))
|
||||
) {
|
||||
effectiveMode = 'read'
|
||||
}
|
||||
|
||||
const status: EntryAccessStatus = effectiveMode === mode ? 'ok' : 'exceeds-role'
|
||||
const relevantLevels =
|
||||
entry.level === 'user' ? [] : entry.level === 'organization' ? orgLevels : projectLevels
|
||||
const failingResources =
|
||||
status === 'exceeds-role'
|
||||
? relevantLevels.filter((level) => rankOf(level.role) < rankOf(requiredRole))
|
||||
: []
|
||||
|
||||
entries[key] = { status, effectiveMode, requiredRole, failingResources }
|
||||
if (status === 'exceeds-role') exceedingEntryKeys.push(key)
|
||||
if (effectiveMode !== 'none') effectiveSelection[key] = effectiveMode
|
||||
}
|
||||
|
||||
return { ...base, entries, exceedingEntryKeys, unavailableEntryKeys, effectiveSelection }
|
||||
}
|
||||
|
||||
export interface FailingResourceGroup {
|
||||
type: 'organization' | 'project'
|
||||
resource: FailingResource
|
||||
entries: { key: string; name: string; mode: PermissionMode; requiredRole: TokenRoleLevel }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverts the evaluation's entry → failingResources mapping into resource → failing entries
|
||||
* (organizations first, then alphabetical) for per-resource breakdowns.
|
||||
*/
|
||||
export const groupFailingResources = (
|
||||
evaluation: TokenAccessEvaluation,
|
||||
selection: PermissionSelection
|
||||
): FailingResourceGroup[] => {
|
||||
const groups = new Map<string, FailingResourceGroup>()
|
||||
for (const key of evaluation.exceedingEntryKeys) {
|
||||
const entryAccess = evaluation.entries[key]
|
||||
const entry = getCatalogEntry(key)
|
||||
const mode = selection[key]
|
||||
if (entryAccess === undefined || entry === undefined || mode === undefined) continue
|
||||
for (const resource of entryAccess.failingResources) {
|
||||
const groupKey = `${resource.type}:${resource.id}`
|
||||
let group = groups.get(groupKey)
|
||||
if (group === undefined) {
|
||||
group = { type: resource.type, resource, entries: [] }
|
||||
groups.set(groupKey, group)
|
||||
}
|
||||
group.entries.push({ key, name: entry.name, mode, requiredRole: entryAccess.requiredRole })
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
if (a.type === b.type) return a.resource.label.localeCompare(b.resource.label)
|
||||
return a.type === 'organization' ? -1 : 1
|
||||
})
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PermissionRowSchema, TokenSchema } from './AccessToken.schemas'
|
||||
|
||||
const validTokenData = {
|
||||
tokenName: 'My Token',
|
||||
expiresAt: 'day',
|
||||
resourceAccess: 'all-orgs' as const,
|
||||
permissionRows: [{ resource: 'organization:billing', actions: ['read'] }],
|
||||
}
|
||||
|
||||
// --- PermissionRowSchema ---
|
||||
|
||||
describe('PermissionRowSchema', () => {
|
||||
it('should pass for a valid permission row', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: 'organization:billing',
|
||||
actions: ['read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail when resource is empty', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: '',
|
||||
actions: ['read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Please select a resource')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when actions is empty', () => {
|
||||
const result = PermissionRowSchema.safeParse({
|
||||
resource: 'organization:billing',
|
||||
actions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Please select at least one action')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when resource is missing', () => {
|
||||
const result = PermissionRowSchema.safeParse({ actions: ['read'] })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when actions is missing', () => {
|
||||
const result = PermissionRowSchema.safeParse({ resource: 'organization:billing' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- TokenSchema ---
|
||||
|
||||
describe('TokenSchema', () => {
|
||||
it('should pass for valid token data', () => {
|
||||
const result = TokenSchema.safeParse(validTokenData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should fail when tokenName is empty', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, tokenName: '' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const nameError = result.error.issues.find((i) => i.path.includes('tokenName'))
|
||||
expect(nameError?.message).toBe('Please enter a name for the token')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when tokenName is missing', () => {
|
||||
const { tokenName, ...rest } = validTokenData
|
||||
const result = TokenSchema.safeParse(rest)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when permissionRows is empty', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, permissionRows: [] })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const permError = result.error.issues.find((i) => i.path.includes('permissionRows'))
|
||||
expect(permError?.message).toBe('Please configure at least one permission')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when resourceAccess is not a valid enum value', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, resourceAccess: 'invalid' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept all valid resourceAccess enum values', () => {
|
||||
for (const value of ['all-orgs', 'selected-orgs', 'selected-projects'] as const) {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, resourceAccess: value })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
describe('expiresAt preprocessing', () => {
|
||||
it('should convert "never" to undefined', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, expiresAt: 'never' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.expiresAt).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass through other string values', () => {
|
||||
const result = TokenSchema.safeParse({ ...validTokenData, expiresAt: 'day' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.expiresAt).toBe('day')
|
||||
}
|
||||
})
|
||||
|
||||
it('should allow expiresAt to be omitted', () => {
|
||||
const { expiresAt, ...rest } = validTokenData
|
||||
const result = TokenSchema.safeParse(rest)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom expiry refinement', () => {
|
||||
it('should fail when expiresAt is "custom" and customExpiryDate is not provided', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: undefined,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const customError = result.error.issues.find((i) => i.path.includes('expiresAt'))
|
||||
expect(customError?.message).toBe('Please select a custom expiry date')
|
||||
}
|
||||
})
|
||||
|
||||
it('should fail when expiresAt is "custom" and customExpiryDate is empty string', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass when expiresAt is "custom" and customExpiryDate is provided', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'custom',
|
||||
customExpiryDate: '2026-12-31T00:00:00Z',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass when expiresAt is not "custom" even without customExpiryDate', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
expiresAt: 'day',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested permissionRows validation', () => {
|
||||
it('should fail when a permission row has an empty resource', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [{ resource: '', actions: ['read'] }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should fail when a permission row has empty actions', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [{ resource: 'organization:billing', actions: [] }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass with multiple valid permission rows', () => {
|
||||
const result = TokenSchema.safeParse({
|
||||
...validTokenData,
|
||||
permissionRows: [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PermissionRowSchema = z.object({
|
||||
resource: z.string().min(1, 'Please select a resource'),
|
||||
actions: z.array(z.string()).min(1, 'Please select at least one action'),
|
||||
})
|
||||
|
||||
export const TokenSchema = z
|
||||
.object({
|
||||
tokenName: z.string().min(1, 'Please enter a name for the token'),
|
||||
expiresAt: z.preprocess((val) => (val === 'never' ? undefined : val), z.string().optional()),
|
||||
customExpiryDate: z.string().optional(),
|
||||
resourceAccess: z.enum(['all-orgs', 'selected-orgs', 'selected-projects']),
|
||||
selectedOrganizations: z.array(z.string()).optional(),
|
||||
selectedProjects: z.array(z.string()).optional(),
|
||||
permissionRows: z.array(PermissionRowSchema).min(1, 'Please configure at least one permission'),
|
||||
})
|
||||
.refine((data) => !(data.expiresAt === 'custom' && !data.customExpiryDate), {
|
||||
message: 'Please select a custom expiry date',
|
||||
path: ['expiresAt'],
|
||||
})
|
||||
|
||||
export type TokenFormValues = z.infer<typeof TokenSchema>
|
||||
@@ -358,10 +358,6 @@ describe('getExpirationDate', () => {
|
||||
expect(result).toBe(dayjs(FIXED_DATE).add(30, 'days').toISOString())
|
||||
})
|
||||
|
||||
it('should return undefined for "never"', () => {
|
||||
expect(getExpirationDate('never')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for an unknown key', () => {
|
||||
expect(getExpirationDate('unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
BaseToken,
|
||||
} from './AccessToken.types'
|
||||
|
||||
/** Custom expiry dates are capped at one year from today, for classic and scoped tokens alike. */
|
||||
export const getMaxCustomExpiryDate = (): dayjs.Dayjs => dayjs().add(1, 'year').endOf('day')
|
||||
|
||||
export const handleSortChange = (
|
||||
currentSort: AccessTokenSort,
|
||||
column: AccessTokenSortColumn,
|
||||
@@ -141,8 +144,6 @@ export const getExpirationDate = (key: string): string | undefined => {
|
||||
return dayjs().add(7, 'days').toISOString()
|
||||
case 'month':
|
||||
return dayjs().add(30, 'days').toISOString()
|
||||
case 'never':
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MoreVertical, Trash } from 'lucide-react'
|
||||
import { Key, MoreVertical, Trash } from 'lucide-react'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -21,38 +21,59 @@ import { filterAndSortTokens, handleSortChange } from './AccessToken.utils'
|
||||
import { RowLoading } from './AccessTokenTable/RowLoading'
|
||||
import { TableContainer } from './AccessTokenTable/TableContainer'
|
||||
import { ExpiresCell, LastUsedCell, TokenNameCell } from './AccessTokenTable/TokenCells'
|
||||
import { useMergedAccessTokens, type MergedAccessToken } from './hooks/useMergedAccessTokens'
|
||||
import { ViewTokenSheet } from './Scoped/ViewTokenSheet'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useAccessTokenDeleteMutation } from '@/data/access-tokens/access-tokens-delete-mutation'
|
||||
import { AccessToken, useAccessTokensQuery } from '@/data/access-tokens/access-tokens-query'
|
||||
import { useScopedAccessTokenDeleteMutation } from '@/data/scoped-access-tokens/scoped-access-tokens-delete-mutation'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export interface AccessTokenListProps {
|
||||
searchString?: string
|
||||
onDeleteSuccess: (id: number) => void
|
||||
scopedTokensEnabled?: boolean
|
||||
onDeleteSuccess: (id: number | string) => void
|
||||
}
|
||||
|
||||
export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTokenListProps) => {
|
||||
export const AccessTokenList = ({
|
||||
searchString = '',
|
||||
scopedTokensEnabled,
|
||||
onDeleteSuccess,
|
||||
}: AccessTokenListProps) => {
|
||||
const track = useTrack()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [token, setToken] = useState<AccessToken | undefined>(undefined)
|
||||
const [tokenToShow, setTokenToShow] = useState<MergedAccessToken | undefined>(undefined)
|
||||
const [tokenToDelete, setTokenToDelete] = useState<MergedAccessToken | undefined>(undefined)
|
||||
const [sort, setSort] = useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral<AccessTokenSort>(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc')
|
||||
)
|
||||
|
||||
const { data: tokens, error, isPending: isLoading, isError } = useAccessTokensQuery()
|
||||
const { tokens, error, isLoading, isError } = useMergedAccessTokens({ scopedTokensEnabled })
|
||||
|
||||
const { mutate: deleteToken } = useAccessTokenDeleteMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
track('access_token_removed', { tokenType: 'classic' })
|
||||
onDeleteSuccess(vars.id)
|
||||
toast.success('Successfully deleted access token')
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete access token: ${error.message}`)
|
||||
},
|
||||
})
|
||||
const { mutate: deleteClassicToken, isPending: isPendingClassicToken } =
|
||||
useAccessTokenDeleteMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
track('access_token_removed', { tokenType: 'classic' })
|
||||
onDeleteSuccess(vars.id)
|
||||
toast.success('Successfully deleted access token')
|
||||
setTokenToDelete(undefined)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete access token: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deleteScopedToken, isPending: isPendingScopedToken } =
|
||||
useScopedAccessTokenDeleteMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
track('access_token_removed', { tokenType: 'scoped' })
|
||||
onDeleteSuccess(vars.id)
|
||||
toast.success('Successfully deleted access token')
|
||||
setTokenToDelete(undefined)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete access token: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const onSortChange = (column: AccessTokenSortColumn) => {
|
||||
handleSortChange(sort, column, setSort)
|
||||
@@ -65,6 +86,12 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
|
||||
const empty = filteredTokens?.length === 0 && !isLoading
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!tokenToDelete) return
|
||||
if (tokenToDelete.kind === 'classic') deleteClassicToken({ id: tokenToDelete.id as number })
|
||||
else deleteScopedToken({ id: tokenToDelete.id as string })
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
@@ -110,7 +137,12 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
{filteredTokens?.map((x) => (
|
||||
<TableRow key={x.token_alias}>
|
||||
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
|
||||
<TokenNameCell
|
||||
name={x.name}
|
||||
tokenAlias={x.token_alias}
|
||||
isClassic={x.kind === 'classic'}
|
||||
scopedTokensEnabled={scopedTokensEnabled}
|
||||
/>
|
||||
<LastUsedCell lastUsedAt={x.last_used_at} />
|
||||
<ExpiresCell expiresAt={x.expires_at} />
|
||||
<TableCell>
|
||||
@@ -119,19 +151,19 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
className="w-7"
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
{x.kind === 'scoped' && (
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => setTokenToShow(x)}>
|
||||
<Key size={12} />
|
||||
<p>View permissions</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => setTokenToDelete(x)}>
|
||||
<Trash size={12} />
|
||||
<p>Delete token</p>
|
||||
</DropdownMenuItem>
|
||||
@@ -144,20 +176,26 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={isOpen}
|
||||
visible={tokenToDelete != null}
|
||||
variant="destructive"
|
||||
title="Confirm to delete"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (token) deleteToken({ id: token.id })
|
||||
}}
|
||||
onCancel={() => setTokenToDelete(undefined)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
loading={isPendingClassicToken || isPendingScopedToken}
|
||||
>
|
||||
<p className="py-4 text-sm text-foreground-light">
|
||||
This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
|
||||
This action cannot be undone. Are you sure you want to delete "{tokenToDelete?.name}"
|
||||
token?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ViewTokenSheet
|
||||
visible={tokenToShow != null}
|
||||
onClose={() => setTokenToShow(undefined)}
|
||||
tokenId={tokenToShow ? String(tokenToShow.id) : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+15
-5
@@ -1,17 +1,27 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Badge, TableCell } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
import { TableCell } from 'ui/src/components/shadcn/ui/table'
|
||||
|
||||
interface TokenNameCellProps {
|
||||
name: string
|
||||
tokenAlias: string
|
||||
isClassic: boolean
|
||||
scopedTokensEnabled?: boolean
|
||||
}
|
||||
|
||||
export const TokenNameCell = ({ name, tokenAlias }: TokenNameCellProps) => (
|
||||
export const TokenNameCell = ({
|
||||
name,
|
||||
tokenAlias,
|
||||
isClassic,
|
||||
scopedTokensEnabled,
|
||||
}: TokenNameCellProps) => (
|
||||
<TableCell className="w-auto max-w-96">
|
||||
<p className="truncate" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="truncate" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
{isClassic && scopedTokensEnabled && <Badge variant="default">Legacy</Badge>}
|
||||
</div>
|
||||
<p
|
||||
className="font-mono text-foreground-lighter truncate text-xs mt-1 max-w-32 sm:max-w-48 lg:max-w-full"
|
||||
title={tokenAlias}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { NewTokenDialog } from './NewTokenDialog'
|
||||
import { type NewAccessToken } from '@/data/access-tokens/access-tokens-create-mutation'
|
||||
|
||||
interface ExperimentalTokenDropdownProps {
|
||||
onCreateToken: (token: NewAccessToken) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The chevron half of the "Generate new token" split button: a dropdown whose single
|
||||
* item opens the experimental API (V0 scope) token dialog. Rendered flush against a
|
||||
* `rounded-r-none` primary button.
|
||||
*/
|
||||
export const ExperimentalTokenDropdown = ({ onCreateToken }: ExperimentalTokenDropdownProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="Choose token scope"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Choose token scope</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem key="experimental-token" onClick={() => setVisible(true)}>
|
||||
<p className="block text-foreground">Generate token for experimental API</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<NewTokenDialog
|
||||
open={visible}
|
||||
onOpenChange={setVisible}
|
||||
tokenScope="V0"
|
||||
onCreateToken={onCreateToken}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { ExperimentalTokenDropdown } from './ExperimentalTokenDropdown'
|
||||
import { NewTokenDialog } from './NewTokenDialog'
|
||||
import { type NewAccessToken } from '@/data/access-tokens/access-tokens-create-mutation'
|
||||
|
||||
@@ -20,56 +11,17 @@ export interface NewAccessTokenButtonProps {
|
||||
|
||||
export const NewTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none px-3"
|
||||
onClick={() => {
|
||||
setTokenScope(undefined)
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
<Button className="rounded-r-none px-3" onClick={() => setVisible(true)}>
|
||||
Generate new token
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="Choose token scope"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Choose token scope</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
key="experimental-token"
|
||||
onClick={() => {
|
||||
setTokenScope('V0')
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="block text-foreground">Generate token for experimental API</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ExperimentalTokenDropdown onCreateToken={onCreateToken} />
|
||||
</div>
|
||||
|
||||
<NewTokenDialog
|
||||
open={visible}
|
||||
onOpenChange={setVisible}
|
||||
tokenScope={tokenScope}
|
||||
onCreateToken={onCreateToken}
|
||||
/>
|
||||
<NewTokenDialog open={visible} onOpenChange={setVisible} onCreateToken={onCreateToken} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,18 +22,17 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
CLASSIC_TOKEN_WARNING,
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
NON_EXPIRING_TOKEN_VALUE,
|
||||
} from '../AccessToken.constants'
|
||||
import { getExpirationDate } from '../AccessToken.utils'
|
||||
import { getExpirationDate, getMaxCustomExpiryDate } from '../AccessToken.utils'
|
||||
import { DatePicker } from '@/components/ui/DatePicker'
|
||||
import {
|
||||
useAccessTokenCreateMutation,
|
||||
@@ -45,15 +44,12 @@ const formId = 'new-access-token-form'
|
||||
|
||||
const TokenSchema = z.object({
|
||||
tokenName: z.string().min(1, 'Please enter a name for the token'),
|
||||
expiresAt: z.preprocess(
|
||||
(val) => (val === NON_EXPIRING_TOKEN_VALUE ? undefined : val),
|
||||
z.string().optional()
|
||||
),
|
||||
expiresAt: z.string().min(1, 'Please select an expiry'),
|
||||
})
|
||||
|
||||
export interface NewAccessTokenDialogProps {
|
||||
open: boolean
|
||||
tokenScope: 'V0' | undefined
|
||||
tokenScope?: 'V0' | undefined
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateToken: (token: NewAccessToken) => void
|
||||
}
|
||||
@@ -81,7 +77,7 @@ export const NewTokenDialog = ({
|
||||
if (isCustomExpiry && customExpiryDate) {
|
||||
expiresAt = customExpiryDate.date
|
||||
} else {
|
||||
expiresAt = getExpirationDate(values.expiresAt || '')
|
||||
expiresAt = getExpirationDate(values.expiresAt)
|
||||
}
|
||||
|
||||
createAccessToken(
|
||||
@@ -90,7 +86,7 @@ export const NewTokenDialog = ({
|
||||
onSuccess: (data) => {
|
||||
track('access_token_created', {
|
||||
tokenType: 'classic',
|
||||
expiryPreset: values.expiresAt || 'never',
|
||||
expiryPreset: values.expiresAt,
|
||||
})
|
||||
toast.success('Access token created successfully')
|
||||
onCreateToken(data)
|
||||
@@ -101,7 +97,7 @@ export const NewTokenDialog = ({
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset({ tokenName: '' })
|
||||
form.reset()
|
||||
setCustomExpiryDate(undefined)
|
||||
setIsCustomExpiry(false)
|
||||
onOpenChange(false)
|
||||
@@ -171,8 +167,8 @@ export const NewTokenDialog = ({
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-t-0 border-x-0"
|
||||
title="Access tokens can be used to control your whole account"
|
||||
description="Be careful when sharing your tokens"
|
||||
title={CLASSIC_TOKEN_WARNING.title}
|
||||
description={CLASSIC_TOKEN_WARNING.description}
|
||||
/>
|
||||
)}
|
||||
<DialogSection className="flex flex-col gap-4">
|
||||
@@ -228,21 +224,13 @@ export const NewTokenDialog = ({
|
||||
contentSide="top"
|
||||
to={customExpiryDate?.date}
|
||||
minDate={new Date()}
|
||||
maxDate={dayjs().add(1, 'year').toDate()}
|
||||
maxDate={getMaxCustomExpiryDate().toDate()}
|
||||
onChange={(date) => {
|
||||
if (date.to) handleCustomDateChange({ date: date.to })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{field.value === NON_EXPIRING_TOKEN_VALUE && (
|
||||
<div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
Make sure to keep your non-expiring token safe and secure.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { Badge, Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export const MigrationAdmonition = () => {
|
||||
const [isDismissed, setIsDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SCOPED_TOKENS_MIGRATION_ADMONITION_DISMISSED,
|
||||
false
|
||||
)
|
||||
|
||||
if (isDismissed) return null
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="We're moving to scoped access tokens"
|
||||
className="relative mb-5"
|
||||
actions={
|
||||
<>
|
||||
{/* Awaiting correct documentation link */}
|
||||
<Button asChild variant="default" size="tiny">
|
||||
<Link href={`${DOCS_URL}/guides/api`} target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => setIsDismissed(true)} aria-label="Close">
|
||||
Dismiss
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<p className="text-sm text-foreground-light">
|
||||
We recommend granting each new token the minimum access its integration needs.
|
||||
</p>
|
||||
<span className="text-sm text-foreground-light">
|
||||
Pre-existing tokens are marked with a <Badge>Legacy</Badge> badge and will continue to
|
||||
work until expiry or deletion.
|
||||
</span>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import {
|
||||
PERMISSION_MODE_LABEL,
|
||||
type PermissionCatalogEntry,
|
||||
type PermissionMode,
|
||||
} from '../AccessToken.permissions'
|
||||
import { TOKEN_ROLE_LABEL, type EntryAccess, type FailingResource } from '../AccessToken.roles'
|
||||
|
||||
const MAX_LISTED_RESOURCES = 5
|
||||
const MAX_LISTED_PROJECT_ROLES = 3
|
||||
|
||||
/**
|
||||
* One line per failing resource. Members invited to projects (not the org) get their real
|
||||
* per-project role spelled out.
|
||||
*/
|
||||
export const failingResourceLine = (resource: FailingResource): string => {
|
||||
if (resource.projectScopedRoles !== undefined && resource.projectScopedRoles.length > 0) {
|
||||
const listed = resource.projectScopedRoles
|
||||
.slice(0, MAX_LISTED_PROJECT_ROLES)
|
||||
.map((project) => `${TOKEN_ROLE_LABEL[project.role]} on the project ${project.label}`)
|
||||
.join(', ')
|
||||
const overflow = resource.projectScopedRoles.length - MAX_LISTED_PROJECT_ROLES
|
||||
const roles = overflow > 0 ? `${listed}, and ${overflow} more` : listed
|
||||
return `${resource.label} — your role is ${roles}`
|
||||
}
|
||||
if (resource.role === 'member' || resource.role === 'none') {
|
||||
return resource.type === 'organization'
|
||||
? `${resource.label} — you don't have an organization-level role`
|
||||
: `${resource.label} — you don't have a role on this project`
|
||||
}
|
||||
return `${resource.label} — your role is ${TOKEN_ROLE_LABEL[resource.role]}`
|
||||
}
|
||||
|
||||
interface ExceedsRoleBadgeProps {
|
||||
entry: PermissionCatalogEntry
|
||||
mode: PermissionMode
|
||||
access: EntryAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* "Exceeds your role" pill with a tooltip naming exactly which resources deny the permission and
|
||||
* why.
|
||||
*/
|
||||
export const ExceedsRoleBadge = ({ entry, mode, access }: ExceedsRoleBadgeProps) => {
|
||||
const failingResources = access.failingResources
|
||||
const overflowCount = failingResources.length - MAX_LISTED_RESOURCES
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>
|
||||
<Badge variant="destructive" className="cursor-help">
|
||||
Exceeds your role
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-80 space-y-1.5">
|
||||
<p className="text-xs">
|
||||
{entry.name} ({PERMISSION_MODE_LABEL[mode]}) requires the{' '}
|
||||
{TOKEN_ROLE_LABEL[access.requiredRole]} role or above
|
||||
{entry.level === 'organization' && ' at the organization level'}. Requests will be denied
|
||||
on:
|
||||
</p>
|
||||
<ul className="text-xs text-foreground-light space-y-0.5">
|
||||
{failingResources.slice(0, MAX_LISTED_RESOURCES).map((resource) => (
|
||||
<li key={resource.id}>{failingResourceLine(resource)}</li>
|
||||
))}
|
||||
{overflowCount > 0 && <li>and {overflowCount} more</li>}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Control, ControllerRenderProps } from 'react-hook-form'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import {
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
NON_EXPIRING_TOKEN_VALUE,
|
||||
} from '../../AccessToken.constants'
|
||||
import { type TokenFormValues } from '../../AccessToken.schemas'
|
||||
import { DatePicker } from '@/components/ui/DatePicker'
|
||||
|
||||
interface BasicInfoProps {
|
||||
control: Control<TokenFormValues>
|
||||
expirationDate: string
|
||||
onCustomDateChange?: (date: { date: string } | undefined) => void
|
||||
onCustomExpiryChange?: (isCustom: boolean) => void
|
||||
}
|
||||
|
||||
export const BasicInfo = ({
|
||||
control,
|
||||
expirationDate,
|
||||
onCustomDateChange,
|
||||
onCustomExpiryChange,
|
||||
}: BasicInfoProps) => {
|
||||
const [customDate, setCustomDate] = useState<Date>()
|
||||
const [isCustomSelected, setIsCustomSelected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const isCustom = expirationDate === CUSTOM_EXPIRY_VALUE
|
||||
setIsCustomSelected(isCustom)
|
||||
onCustomExpiryChange?.(isCustom)
|
||||
}, [expirationDate, onCustomExpiryChange])
|
||||
|
||||
const handleCustomDateChange = (date: Date | undefined) => {
|
||||
setCustomDate(date)
|
||||
if (date) {
|
||||
onCustomDateChange?.({ date: date.toISOString() })
|
||||
} else {
|
||||
onCustomDateChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpiryChange = (
|
||||
value: string,
|
||||
field: ControllerRenderProps<TokenFormValues, 'expiresAt'>
|
||||
) => {
|
||||
const isCustom = value === CUSTOM_EXPIRY_VALUE
|
||||
setIsCustomSelected(isCustom)
|
||||
onCustomExpiryChange?.(isCustom)
|
||||
field.onChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<FormField
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name">
|
||||
<FormControl>
|
||||
<Input id="tokenName" {...field} placeholder="Provide a name for your token" />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires in">
|
||||
<div className="flex gap-2">
|
||||
<FormControl className="grow">
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => handleExpiryChange(value, field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Expires at" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(EXPIRES_AT_OPTIONS).map(
|
||||
(option: { value: string; label: string }) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{isCustomSelected && (
|
||||
<DatePicker
|
||||
selectsRange={false}
|
||||
triggerButtonSize="small"
|
||||
contentSide="top"
|
||||
minDate={new Date()}
|
||||
maxDate={dayjs().add(1, 'year').toDate()}
|
||||
onChange={(date) => {
|
||||
const selectedDate = date.to || date.from
|
||||
if (selectedDate) {
|
||||
handleCustomDateChange(new Date(selectedDate))
|
||||
} else {
|
||||
handleCustomDateChange(undefined)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{customDate ? `${dayjs(customDate).format('DD MMM, HH:mm')}` : 'Select date'}
|
||||
</DatePicker>
|
||||
)}
|
||||
</div>
|
||||
{field.value === NON_EXPIRING_TOKEN_VALUE && (
|
||||
<div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
Make sure to keep your non-expiring token safe and secure.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Form, ScrollArea, Separator, SheetClose, SheetFooter } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
|
||||
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
|
||||
import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation'
|
||||
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
|
||||
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
|
||||
import { PermissionsAccordion } from './PermissionsAccordion'
|
||||
import { ResourceAccessStep } from './ResourceAccessStep'
|
||||
import { StepIndicator } from './StepIndicator'
|
||||
import { TokenDetails } from './TokenDetails'
|
||||
import { InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const FORM_ID = 'scoped-token-form'
|
||||
|
||||
const DEFAULT_VALUES: TokenFormValues = {
|
||||
tokenName: '',
|
||||
expiresAt: DEFAULT_EXPIRY,
|
||||
customExpiryDate: undefined,
|
||||
resourceAccess: 'project',
|
||||
organizationSlugs: [],
|
||||
projectRefs: [],
|
||||
permissions: {},
|
||||
}
|
||||
|
||||
export const NewScopedTokenForm = ({
|
||||
isPending,
|
||||
onCreateToken,
|
||||
onCancel,
|
||||
}: {
|
||||
isPending: boolean
|
||||
onCreateToken: (values: TokenFormValues) => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
const form = useForm<TokenFormValues>({
|
||||
resolver: zodResolver(TokenFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
mode: 'onChange',
|
||||
})
|
||||
const [step, setStep] = useState<'form' | 'review'>('form')
|
||||
const [formValues, setFormValues] = useState<TokenFormValues>(DEFAULT_VALUES)
|
||||
const [showMissingPermissionsWarning, setShowMissingPermissionsWarning] = useState(false)
|
||||
const resourceSectionRef = useRef<HTMLDivElement>(null)
|
||||
const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' })
|
||||
const selection = useWatch({ control: form.control, name: 'permissions' })
|
||||
const organizationSlugs = useWatch({
|
||||
control: form.control,
|
||||
name: 'organizationSlugs',
|
||||
defaultValue: [],
|
||||
})
|
||||
const projectRefs = useWatch({ control: form.control, name: 'projectRefs', defaultValue: [] })
|
||||
const configuredCount = useWatch({
|
||||
control: form.control,
|
||||
name: 'permissions',
|
||||
compute: (selection) => countConfigured(selection),
|
||||
})
|
||||
|
||||
const access = useTokenAccessEvaluation({
|
||||
selection,
|
||||
resourceAccess,
|
||||
organizationSlugs,
|
||||
projectRefs,
|
||||
enabled: resourceAccess !== 'account',
|
||||
})
|
||||
|
||||
const { data: permissionScopeMap, isError } = useGetEnabledEndpointsForCapability()
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.error('Something went wrong, try again')
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel, isError])
|
||||
|
||||
// 'account' switches to the classic token flow: name + expiry only, no permissions or review.
|
||||
const isClassicMode = resourceAccess === 'account'
|
||||
|
||||
// Single owner of the mode switch, so every entry point resets the same dependent fields.
|
||||
const handleSelectLegacyMode = () => {
|
||||
form.setValue('resourceAccess', 'account', { shouldValidate: true })
|
||||
form.setValue('organizationSlugs', [])
|
||||
form.setValue('projectRefs', [])
|
||||
// The fields unmount in legacy mode, so drop any validation errors they were holding.
|
||||
form.clearErrors(['organizationSlugs', 'projectRefs'])
|
||||
}
|
||||
|
||||
const handleReviewAccess = async (values: TokenFormValues) => {
|
||||
if (values.resourceAccess === 'account') {
|
||||
onCreateToken(values)
|
||||
return
|
||||
}
|
||||
if (configuredCount === 0) {
|
||||
setShowMissingPermissionsWarning(true)
|
||||
return
|
||||
}
|
||||
setFormValues(values)
|
||||
setStep('review')
|
||||
}
|
||||
|
||||
const handlePermissionChange = (key: string, mode: PermissionMode) => {
|
||||
form.setValue('permissions', { ...selection, [key]: mode })
|
||||
if (mode !== 'none') setShowMissingPermissionsWarning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="flex-1">
|
||||
{step === 'form' ? (
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={form.handleSubmit(handleReviewAccess)}>
|
||||
{isClassicMode && (
|
||||
<div className="px-5 sm:px-6 pt-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mb-0"
|
||||
title={CLASSIC_TOKEN_WARNING.title}
|
||||
description={CLASSIC_TOKEN_WARNING.description}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TokenDetails control={form.control} setValue={form.setValue} />
|
||||
{isClassicMode ? (
|
||||
<p className="px-5 sm:px-6 pb-6 text-foreground-lighter text-sm">
|
||||
Only need a token for specific projects or organizations?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={InlineLinkClassName}
|
||||
onClick={() =>
|
||||
form.setValue('resourceAccess', 'project', { shouldValidate: true })
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
Create scoped token
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Separator />
|
||||
<div ref={resourceSectionRef}>
|
||||
<ResourceAccessStep
|
||||
control={form.control}
|
||||
setValue={form.setValue}
|
||||
onSelectLegacyToken={handleSelectLegacyMode}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<PermissionsAccordion
|
||||
selection={selection}
|
||||
onChange={handlePermissionChange}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
access={access}
|
||||
/>
|
||||
{showMissingPermissionsWarning && (
|
||||
<div className="space-y-3 px-5 sm:px-6 pb-6">
|
||||
<Admonition
|
||||
ref={(node) => {
|
||||
node?.scrollIntoView()
|
||||
}}
|
||||
type="warning"
|
||||
title="No permissions selected"
|
||||
description="This token won't be able to do anything until you grant at least one permission."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<NewScopedTokenFormReview
|
||||
values={formValues}
|
||||
access={access}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
onSelectLegacyToken={() => {
|
||||
handleSelectLegacyMode()
|
||||
setStep('form')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-auto flex w-full items-center justify-between! border-t py-4 px-5 sm:px-6">
|
||||
{isClassicMode ? (
|
||||
<span />
|
||||
) : (
|
||||
<StepIndicator step={step === 'form' ? 1 : 2} total={2} label="Configure" />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{step === 'review' && (
|
||||
<>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
Access can't be changed after creation
|
||||
</span>
|
||||
<Button variant="default" disabled={isPending} onClick={() => setStep('form')}>
|
||||
Back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<SheetClose asChild disabled={isPending}>
|
||||
<Button variant="default">Cancel</Button>
|
||||
</SheetClose>
|
||||
{step === 'form' && isClassicMode && (
|
||||
<Button type="submit" form={FORM_ID} loading={isPending}>
|
||||
Generate token
|
||||
</Button>
|
||||
)}
|
||||
{step === 'form' && !isClassicMode && (
|
||||
<Button type="submit" form={FORM_ID} iconRight={<ChevronRight />}>
|
||||
Review access
|
||||
</Button>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<Button loading={isPending} onClick={() => onCreateToken(formValues)}>
|
||||
Create token
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { TokenFormSchema } from './NewScopedTokenForm.utils'
|
||||
|
||||
const BASE_VALUES = {
|
||||
tokenName: 'test',
|
||||
expiresAt: 'custom',
|
||||
resourceAccess: 'account',
|
||||
organizationSlugs: [],
|
||||
projectRefs: [],
|
||||
permissions: {},
|
||||
}
|
||||
|
||||
describe('TokenFormSchema custom expiry', () => {
|
||||
test('requires a date when the preset is custom', () => {
|
||||
const result = TokenFormSchema.safeParse(BASE_VALUES)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
test('accepts a date within one year from today', () => {
|
||||
const result = TokenFormSchema.safeParse({
|
||||
...BASE_VALUES,
|
||||
customExpiryDate: dayjs().add(1, 'year').startOf('day').toISOString(),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
test('rejects a date more than one year from today', () => {
|
||||
const result = TokenFormSchema.safeParse({
|
||||
...BASE_VALUES,
|
||||
customExpiryDate: dayjs().add(1, 'year').add(1, 'day').endOf('day').toISOString(),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error?.issues.map((issue) => issue.message)).toContain(
|
||||
'Expiry date must be within one year from today'
|
||||
)
|
||||
})
|
||||
})
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { PermissionMode } from '../../AccessToken.permissions'
|
||||
import { getMaxCustomExpiryDate } from '../../AccessToken.utils'
|
||||
|
||||
export const EXPIRY_PRESETS = ['24h', '7d', '30d', '90d', 'custom'] as const
|
||||
export type ExpiryPreset = (typeof EXPIRY_PRESETS)[number]
|
||||
|
||||
export const DEFAULT_EXPIRY: ExpiryPreset = '7d'
|
||||
|
||||
export interface ExpiryOption {
|
||||
value: ExpiryPreset
|
||||
label: string
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
export const EXPIRY_OPTIONS: ExpiryOption[] = [
|
||||
{ value: '24h', label: '24 hours' },
|
||||
{ value: '7d', label: '7 days', recommended: true },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: '90d', label: '90 days' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
/** Resolves the final ISO expiry date for a preset, or `undefined` when the caller supplies a custom date. */
|
||||
export const getExpiryDate = (preset: ExpiryPreset): string | undefined => {
|
||||
switch (preset) {
|
||||
case '24h':
|
||||
return dayjs().add(24, 'hours').toISOString()
|
||||
case '7d':
|
||||
return dayjs().add(7, 'days').toISOString()
|
||||
case '30d':
|
||||
return dayjs().add(30, 'days').toISOString()
|
||||
case '90d':
|
||||
return dayjs().add(90, 'days').toISOString()
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The default custom date sits ~7 days out, matching the recommended preset. */
|
||||
export const getDefaultCustomExpiryDate = (): string =>
|
||||
dayjs().add(7, 'days').endOf('day').toISOString()
|
||||
|
||||
export const RESOURCE_ACCESS_MODES = ['project', 'organization', 'account'] as const
|
||||
|
||||
/**
|
||||
* The form schema is intentionally permissive: resource-selection and permission-count validation
|
||||
* happen imperatively on step transitions so errors only surface after an attempt (never
|
||||
* preemptively), and can be scrolled into view. Zod only guards always-required basics.
|
||||
*/
|
||||
export const TokenFormSchema = z
|
||||
.object({
|
||||
tokenName: z.string().trim().min(1, 'Please enter a name for the token'),
|
||||
expiresAt: z.enum(EXPIRY_PRESETS),
|
||||
customExpiryDate: z.string().datetime().optional(),
|
||||
resourceAccess: z.enum(RESOURCE_ACCESS_MODES),
|
||||
organizationSlugs: z.string().array().optional().default([]),
|
||||
projectRefs: z.string().array().optional().default([]),
|
||||
permissions: z.record(z.string(), z.enum(['none', 'read', 'readwrite'])),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.expiresAt === 'custom' && !data.customExpiryDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Required',
|
||||
path: ['customExpiryDate'],
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
data.expiresAt === 'custom' &&
|
||||
data.customExpiryDate &&
|
||||
dayjs(data.customExpiryDate).isAfter(getMaxCustomExpiryDate())
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Expiry date must be within one year from today',
|
||||
path: ['customExpiryDate'],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.resourceAccess === 'project') {
|
||||
if (data.organizationSlugs.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Please select an organization to continue.',
|
||||
path: ['organizationSlugs'],
|
||||
})
|
||||
}
|
||||
if (data.projectRefs.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Please select a project to continue.',
|
||||
path: ['projectRefs'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.resourceAccess === 'organization' && data.organizationSlugs.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Please select an organization to continue.',
|
||||
path: ['organizationSlugs'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type TokenFormValues = Omit<z.infer<typeof TokenFormSchema>, 'permissions'> & {
|
||||
permissions: Record<string, PermissionMode>
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import {
|
||||
computeOverallRisk,
|
||||
PERMISSION_MODE_LABEL,
|
||||
selectionToScopes,
|
||||
} from '../../AccessToken.permissions'
|
||||
import {
|
||||
groupFailingResources,
|
||||
TOKEN_ROLE_LABEL,
|
||||
type TokenAccessEvaluation,
|
||||
} from '../../AccessToken.roles'
|
||||
import { useCapabilitySummary } from '../../hooks/useCapabilitySummary'
|
||||
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
|
||||
import { failingResourceLine } from '../ExceedsRoleBadge'
|
||||
import { McpUnsupportedWarning } from '../McpUnsupportedWarning'
|
||||
import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from '../TokenSummaryRows'
|
||||
import { EXPIRY_OPTIONS, type TokenFormValues } from './NewScopedTokenForm.utils'
|
||||
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface ReviewStepProps {
|
||||
values: TokenFormValues
|
||||
access: TokenAccessEvaluation
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
/** Switches the form back to step one in legacy (account-wide) token mode. */
|
||||
onSelectLegacyToken: () => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenFormReview = ({
|
||||
values,
|
||||
access,
|
||||
permissionScopeMap,
|
||||
onSelectLegacyToken,
|
||||
}: ReviewStepProps) => {
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
const selection = values.permissions
|
||||
const grantedScopes = useMemo(() => selectionToScopes(selection), [selection])
|
||||
|
||||
const hasExceedingCapabilities = access.exceedingEntryKeys.length > 0
|
||||
|
||||
// Exceeded permissions grouped by the resource where they fail, so the admonition reads per
|
||||
// org/project rather than as one flat permission list.
|
||||
const exceedingByResource = useMemo(
|
||||
() => groupFailingResources(access, selection),
|
||||
[access, selection]
|
||||
)
|
||||
|
||||
const risk = useMemo(
|
||||
() => computeOverallRisk(access.effectiveSelection, values.resourceAccess),
|
||||
[access.effectiveSelection, values.resourceAccess]
|
||||
)
|
||||
|
||||
const resourceSummary = useMemo(() => {
|
||||
if (values.resourceAccess === 'project') {
|
||||
const selectedProjects = projects.filter((p) => values.projectRefs.includes(p.ref))
|
||||
return {
|
||||
title: 'Projects',
|
||||
items:
|
||||
selectedProjects.length > 0
|
||||
? selectedProjects.map((p) => ({ key: p.ref, label: p.name, sublabel: p.ref }))
|
||||
: [{ key: 'none', label: '-', sublabel: undefined }],
|
||||
}
|
||||
}
|
||||
if (values.resourceAccess === 'organization') {
|
||||
const selectedOrganizations = organizations.filter((o) =>
|
||||
values.organizationSlugs.includes(o.slug)
|
||||
)
|
||||
return {
|
||||
title: 'Organizations',
|
||||
items:
|
||||
selectedOrganizations.length > 0
|
||||
? selectedOrganizations.map((o) => ({ key: o.slug, label: o.name, sublabel: o.slug }))
|
||||
: [{ key: 'none', label: '-', sublabel: undefined }],
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Account',
|
||||
items: [{ key: 'account', label: 'Account-level access', sublabel: undefined }],
|
||||
}
|
||||
}, [values, projects, organizations])
|
||||
|
||||
const expiresSummary = useMemo(() => {
|
||||
if (values.expiresAt === 'custom') {
|
||||
return values.customExpiryDate
|
||||
? dayjs(values.customExpiryDate).format('DD MMM, YYYY')
|
||||
: 'Custom — no date set'
|
||||
}
|
||||
return EXPIRY_OPTIONS.find((o) => o.value === values.expiresAt)?.label ?? values.expiresAt
|
||||
}, [values])
|
||||
|
||||
const hasCapabilities = grantedScopes.length > 0
|
||||
|
||||
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
|
||||
selection,
|
||||
grantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
|
||||
const rows: [string, React.ReactNode][] = [
|
||||
['Name', values.tokenName || <span className="text-foreground-lighter">Untitled token</span>],
|
||||
['Expires', expiresSummary],
|
||||
[
|
||||
'Resource access',
|
||||
<div key="resource-access" className="space-y-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
{resourceSummary.title}
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{resourceSummary.items.map((item) => (
|
||||
<ResourceSummaryItem key={item.key} label={item.label} sublabel={item.sublabel} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
'Capabilities',
|
||||
hasCapabilities ? (
|
||||
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
|
||||
) : (
|
||||
<span className="text-foreground-lighter">No capabilities selected</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Risk level',
|
||||
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
|
||||
],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-5 sm:px-6 py-6">
|
||||
{!hasCapabilities && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="This token has no capabilities"
|
||||
description="Go back and grant at least one permission before creating it."
|
||||
/>
|
||||
)}
|
||||
{hasExceedingCapabilities && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Some permissions exceed your current role for the selected resources"
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
A token only works with permissions you currently hold. Requests with these
|
||||
permissions will be denied until your role includes them:
|
||||
</p>
|
||||
{exceedingByResource.map((group) => (
|
||||
<div key={`${group.type}:${group.resource.id}`}>
|
||||
<p className="font-medium">{failingResourceLine(group.resource)}</p>
|
||||
<ul className="list-disc pl-4">
|
||||
{group.entries.map((groupEntry) => (
|
||||
<li key={groupEntry.key}>
|
||||
{groupEntry.name} ({PERMISSION_MODE_LABEL[groupEntry.mode]}) — requires{' '}
|
||||
{TOKEN_ROLE_LABEL[groupEntry.requiredRole]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Token summary</h3>
|
||||
<dl className="divide-y rounded-md border bg-surface-300">
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className="grid grid-cols-3 gap-4 px-4 py-3">
|
||||
<dt className="text-sm text-foreground-lighter">{key}</dt>
|
||||
<dd className="col-span-2 text-sm text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{hasCapabilities && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Management API endpoints enabled</h3>
|
||||
{capabilityGroups.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No Management API endpoints are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
capabilityGroups.map(({ entry, mode, endpoints }) => (
|
||||
<div key={entry.key} className="rounded-md border">
|
||||
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
|
||||
<span className="text-xs text-foreground">{entry.name}</span>
|
||||
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
|
||||
{PERMISSION_MODE_LABEL[mode]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{endpoints.map(([method, path]) => (
|
||||
<div
|
||||
key={`${method} ${path}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span className="w-14 shrink-0 text-foreground-light">{method}</span>
|
||||
<span className="text-foreground">{path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">MCP tools</h3>
|
||||
<McpUnsupportedWarning onSelectLegacyToken={onSelectLegacyToken} />
|
||||
{mcpTools.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No MCP tools are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcpTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded border bg-surface-100 px-2 py-1 font-mono text-xs text-foreground-light"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Checkbox, ScrollArea, SheetFooter } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
interface TokenSuccessProps {
|
||||
tokenName: string
|
||||
tokenValue: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenSuccess = ({ tokenName, tokenValue, onClose }: TokenSuccessProps) => {
|
||||
const [keyCopied, setKeyCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm text-foreground">Token created</h3>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Copy your new token{tokenName ? ` "${tokenName}"` : ''} and store it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="input-mono w-full"
|
||||
id="scoped-access-token-value"
|
||||
value={tokenValue}
|
||||
onChange={() => {}}
|
||||
aria-label={`${tokenName} token`}
|
||||
onCopy={() => toast.success('Token copied to clipboard')}
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="This is the only time the token is shown"
|
||||
description="You won't be able to see this token value again. If you lose it, revoke the token and create a new one."
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="flex items-center gap-3 cursor-pointer py-2">
|
||||
<Checkbox
|
||||
id="key-copied"
|
||||
checked={keyCopied}
|
||||
onCheckedChange={(v) => setKeyCopied(Boolean(v))}
|
||||
/>
|
||||
<span className="text-sm text-warning cursor-pointer select-none">
|
||||
I have copied the key and stored it securely
|
||||
</span>
|
||||
</label>
|
||||
</Admonition>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-auto flex w-full items-center justify-between! border-t py-4">
|
||||
<Button className="ml-auto" disabled={!keyCopied} onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui'
|
||||
|
||||
import type { PermissionCatalogEntry, PermissionMode } from '../../AccessToken.permissions'
|
||||
import type { EntryAccess } from '../../AccessToken.roles'
|
||||
import { ExceedsRoleBadge } from '../ExceedsRoleBadge'
|
||||
import { RiskMarker } from './RiskMarker'
|
||||
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface PermissionRowProps {
|
||||
entry: PermissionCatalogEntry
|
||||
mode: PermissionMode
|
||||
onChange: (mode: PermissionMode) => void
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
entryAccess?: EntryAccess
|
||||
}
|
||||
|
||||
export const PermissionRow = ({
|
||||
entry,
|
||||
mode,
|
||||
onChange,
|
||||
permissionScopeMap,
|
||||
entryAccess,
|
||||
}: PermissionRowProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="flex items-start md:items-center gap-2 flex-col-reverse md:flex-row">
|
||||
<Label htmlFor={`${entry.key}-permissions`}>
|
||||
<span className="text-sm text-foreground">
|
||||
{entry.name} <span className="sr-only">permissions</span>
|
||||
</span>
|
||||
</Label>
|
||||
<RiskMarker entry={entry} permissionScopeMap={permissionScopeMap} />
|
||||
{entryAccess?.status === 'exceeds-role' && (
|
||||
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
|
||||
)}
|
||||
</span>
|
||||
<p id={`${entry.key}-permissions-description`} className="text-xs text-foreground-lighter">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select value={mode} onValueChange={(value) => onChange(value as PermissionMode)}>
|
||||
<SelectTrigger
|
||||
className="w-36 shrink-0"
|
||||
id={`${entry.key}-permissions`}
|
||||
aria-describedby={`${entry.key}-permissions-description`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="read">Read</SelectItem>
|
||||
{entry.writable && <SelectItem value="readwrite">Read-write</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
import { Key, Plus } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { PermissionResourceSelectorProps, PermissionRow } from './Permissions.types'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
|
||||
export const PermissionResourceSelector = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResourceToggled,
|
||||
permissionRows,
|
||||
align = 'center',
|
||||
}: PermissionResourceSelectorProps) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="default" size="tiny" icon={<Plus className="h-4 w-4" />}>
|
||||
Add permission
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search resources..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No resources found.</CommandEmpty>
|
||||
|
||||
<CommandGroup className="[&>div]:text-left">
|
||||
<div className="max-h-[210px] overflow-y-auto">
|
||||
{ACCESS_TOKEN_RESOURCES.map((resource) => {
|
||||
const isChecked = permissionRows.some(
|
||||
(row: PermissionRow) => row.resource === resource.resource
|
||||
)
|
||||
return (
|
||||
<CommandItem
|
||||
key={resource.resource}
|
||||
value={`${resource.resource} ${resource.title}`}
|
||||
onSelect={() => onResourceToggled(resource)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => onResourceToggled(resource)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Key size={12} className="text-foreground-lighter" />
|
||||
<div className="flex flex-col text-left flex-1">
|
||||
<span className="font-medium text-foreground capitalize">
|
||||
{resource.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { ChevronDown, RotateCcw, X } from 'lucide-react'
|
||||
import { useFieldArray, useFormState } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
|
||||
import { TokenFormValues } from '../../../AccessToken.schemas'
|
||||
import { PermissionResourceSelector } from './PermissionResourceSelector'
|
||||
import { PermissionsProps } from './Permissions.types'
|
||||
import { sortActions } from './Permissions.utils'
|
||||
import { ACCESS_TOKEN_RESOURCES } from '@/components/interfaces/Account/AccessTokens/AccessToken.constants'
|
||||
import { formatAccessText } from '@/components/interfaces/Account/AccessTokens/AccessToken.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
export const Permissions = ({
|
||||
control,
|
||||
resourceSearchOpen,
|
||||
setResourceSearchOpen,
|
||||
}: PermissionsProps) => {
|
||||
const {
|
||||
fields: permissionRows,
|
||||
append,
|
||||
remove,
|
||||
} = useFieldArray<TokenFormValues>({
|
||||
name: 'permissionRows',
|
||||
control,
|
||||
})
|
||||
const { errors } = useFormState({ control, name: 'permissionRows' })
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Configure permissions</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissionRows.length > 0 && (
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => remove()}
|
||||
icon={<RotateCcw size={16} />}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
alignOffset: -10,
|
||||
text: 'Reset all permissions',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PermissionResourceSelector
|
||||
open={resourceSearchOpen}
|
||||
onOpenChange={setResourceSearchOpen}
|
||||
permissionRows={permissionRows}
|
||||
onResourceToggled={(resource) => {
|
||||
const index = permissionRows.findIndex((p) => p.resource === resource.resource)
|
||||
if (index > -1) {
|
||||
return remove(index)
|
||||
}
|
||||
append(resource)
|
||||
}}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissionRows.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-lg">
|
||||
<p className="text-sm text-foreground-light">No permissions configured yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{permissionRows.map((row, index) => {
|
||||
const selectedResource = ACCESS_TOKEN_RESOURCES.find(
|
||||
(r) => r.resource === row.resource
|
||||
)
|
||||
return (
|
||||
<FormField
|
||||
key={row.id}
|
||||
name={`permissionRows.${index}.actions`}
|
||||
render={({ field, fieldState }) => {
|
||||
const fieldValue = field.value || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium truncate max-w-[36ch] capitalize">
|
||||
{selectedResource?.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedResource && (
|
||||
<Popover>
|
||||
<FormControl>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={`permissionRows.${index}.actions`}
|
||||
aria-describedby={
|
||||
fieldState.invalid
|
||||
? `permissionRows.${index}.actions.error`
|
||||
: undefined
|
||||
}
|
||||
variant="default"
|
||||
size="tiny"
|
||||
className="w-[150px] flex text-sm justify-between h-7 "
|
||||
iconRight={
|
||||
<ChevronDown size={14} className="text-foreground-muted" />
|
||||
}
|
||||
ref={field.ref}
|
||||
>
|
||||
{fieldValue.length === 0 ? (
|
||||
<span className="text-foreground-lighter">
|
||||
Select access
|
||||
</span>
|
||||
) : fieldValue.length === 1 ? (
|
||||
formatAccessText(fieldValue[0])
|
||||
) : (
|
||||
`${fieldValue.length} selected`
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</FormControl>
|
||||
<PopoverContent className="w-[180px] p-2" align="end">
|
||||
<div className="space-y-2">
|
||||
{sortActions(selectedResource.actions).map((action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={fieldValue.includes(action)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newActions = checked
|
||||
? [...fieldValue, action]
|
||||
: fieldValue.filter((a: string) => a !== action)
|
||||
field.onChange(newActions)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{formatAccessText(action)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
remove(index)
|
||||
}}
|
||||
icon={<X size={16} />}
|
||||
aria-label="Remove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 pt-0">
|
||||
<FormMessage id={`permissionRows.${index}.actions.error`} />
|
||||
</div>
|
||||
{index < permissionRows.length - 1 && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-x-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
Once you've set these permissions, you cannot edit them.
|
||||
</span>
|
||||
</div>
|
||||
{errors.permissionRows?.message || errors.permissionRows?.root?.message ? (
|
||||
<p role="alert" className="mt-2 text-sm text-destructive">
|
||||
{errors.permissionRows?.message || errors.permissionRows?.root?.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { Control, FieldValues } from 'react-hook-form'
|
||||
|
||||
import { TokenFormValues } from '../../../AccessToken.schemas'
|
||||
|
||||
export interface PermissionResource {
|
||||
resource: string
|
||||
title: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PermissionRow {
|
||||
resource: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PermissionsFormValues extends FieldValues {
|
||||
permissionRows?: PermissionRow[]
|
||||
}
|
||||
|
||||
export interface PermissionsProps {
|
||||
control: Control<TokenFormValues>
|
||||
resourceSearchOpen: boolean
|
||||
setResourceSearchOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export interface PermissionResourceSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
permissionRows: PermissionRow[]
|
||||
onResourceToggled: (resource: PermissionResource) => void
|
||||
align?: 'center' | 'end' | 'start'
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PermissionResource, PermissionRow } from './Permissions.types'
|
||||
import { sortActions, togglePermissionResource } from './Permissions.utils'
|
||||
|
||||
// --- sortActions ---
|
||||
|
||||
describe('sortActions', () => {
|
||||
it('should sort actions by priority order: read, write, create, delete', () => {
|
||||
expect(sortActions(['delete', 'create', 'write', 'read'])).toEqual([
|
||||
'read',
|
||||
'write',
|
||||
'create',
|
||||
'delete',
|
||||
])
|
||||
})
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(sortActions([])).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep a single action as-is', () => {
|
||||
expect(sortActions(['write'])).toEqual(['write'])
|
||||
})
|
||||
|
||||
it('should place unknown actions after the priority ones', () => {
|
||||
expect(sortActions(['custom-action', 'read', 'delete'])).toEqual([
|
||||
'read',
|
||||
'delete',
|
||||
'custom-action',
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve relative order of multiple unknown actions', () => {
|
||||
expect(sortActions(['zeta', 'alpha', 'read'])).toEqual(['read', 'zeta', 'alpha'])
|
||||
})
|
||||
|
||||
it('should handle only unknown actions', () => {
|
||||
expect(sortActions(['foo', 'bar'])).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should handle a subset of priority actions', () => {
|
||||
expect(sortActions(['delete', 'read'])).toEqual(['read', 'delete'])
|
||||
})
|
||||
|
||||
it('should not mutate the original array', () => {
|
||||
const original = ['delete', 'read']
|
||||
sortActions(original)
|
||||
expect(original).toEqual(['delete', 'read'])
|
||||
})
|
||||
})
|
||||
|
||||
// --- togglePermissionResource ---
|
||||
|
||||
describe('togglePermissionResource', () => {
|
||||
const billingResource: PermissionResource = {
|
||||
resource: 'organization:billing',
|
||||
title: 'Billing',
|
||||
actions: ['read', 'write'],
|
||||
}
|
||||
|
||||
const membersResource: PermissionResource = {
|
||||
resource: 'organization:members',
|
||||
title: 'Members',
|
||||
actions: ['read', 'write', 'create', 'delete'],
|
||||
}
|
||||
|
||||
const storageResource: PermissionResource = {
|
||||
resource: 'project:storage',
|
||||
title: 'Storage',
|
||||
actions: ['write', 'create'],
|
||||
}
|
||||
|
||||
it('should add a resource with "read" as the default action when available', () => {
|
||||
const result = togglePermissionResource([], billingResource)
|
||||
expect(result).toEqual([{ resource: 'organization:billing', actions: ['read'] }])
|
||||
})
|
||||
|
||||
it('should add a resource with the first action as default when "read" is not available', () => {
|
||||
const result = togglePermissionResource([], storageResource)
|
||||
expect(result).toEqual([{ resource: 'project:storage', actions: ['write'] }])
|
||||
})
|
||||
|
||||
it('should remove a resource if it is already in the list', () => {
|
||||
const existing: PermissionRow[] = [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
]
|
||||
const result = togglePermissionResource(existing, billingResource)
|
||||
expect(result).toEqual([{ resource: 'organization:members', actions: ['read', 'write'] }])
|
||||
})
|
||||
|
||||
it('should not modify other rows when removing a resource', () => {
|
||||
const existing: PermissionRow[] = [
|
||||
{ resource: 'organization:billing', actions: ['read'] },
|
||||
{ resource: 'organization:members', actions: ['read', 'write'] },
|
||||
]
|
||||
const result = togglePermissionResource(existing, billingResource)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ resource: 'organization:members', actions: ['read', 'write'] })
|
||||
})
|
||||
|
||||
it('should append to existing rows when adding', () => {
|
||||
const existing: PermissionRow[] = [{ resource: 'organization:billing', actions: ['read'] }]
|
||||
const result = togglePermissionResource(existing, membersResource)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[1]).toEqual({ resource: 'organization:members', actions: ['read'] })
|
||||
})
|
||||
|
||||
it('should not mutate the original array', () => {
|
||||
const existing: PermissionRow[] = [{ resource: 'organization:billing', actions: ['read'] }]
|
||||
const original = [...existing]
|
||||
togglePermissionResource(existing, membersResource)
|
||||
expect(existing).toEqual(original)
|
||||
})
|
||||
|
||||
it('should toggle off then on again correctly', () => {
|
||||
const step1 = togglePermissionResource([], billingResource)
|
||||
expect(step1).toHaveLength(1)
|
||||
|
||||
const step2 = togglePermissionResource(step1, billingResource)
|
||||
expect(step2).toHaveLength(0)
|
||||
|
||||
const step3 = togglePermissionResource(step2, billingResource)
|
||||
expect(step3).toHaveLength(1)
|
||||
expect(step3[0]).toEqual({ resource: 'organization:billing', actions: ['read'] })
|
||||
})
|
||||
})
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { PermissionResource, PermissionRow } from './Permissions.types'
|
||||
|
||||
export const sortActions = (actions: string[]): string[] => {
|
||||
const sorted: string[] = []
|
||||
const remaining = [...actions]
|
||||
const priority = ['read', 'write', 'create', 'delete']
|
||||
|
||||
for (const action of priority) {
|
||||
const index = remaining.indexOf(action)
|
||||
if (index !== -1) {
|
||||
sorted.push(action)
|
||||
remaining.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
sorted.push(...remaining)
|
||||
return sorted
|
||||
}
|
||||
|
||||
export const togglePermissionResource = (
|
||||
permissionRows: PermissionRow[],
|
||||
resource: PermissionResource
|
||||
): PermissionRow[] => {
|
||||
const isAlreadyAdded = permissionRows.some((row) => row.resource === resource.resource)
|
||||
|
||||
if (isAlreadyAdded) {
|
||||
return permissionRows.filter((row) => row.resource !== resource.resource)
|
||||
}
|
||||
|
||||
const defaultActions = resource.actions.includes('read') ? ['read'] : [resource.actions[0]]
|
||||
return [...permissionRows, { resource: resource.resource, actions: defaultActions }]
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, cn } from 'ui'
|
||||
|
||||
import {
|
||||
countConfiguredInCategory,
|
||||
PERMISSION_CATALOG_BY_CATEGORY,
|
||||
type PermissionMode,
|
||||
type PermissionSelection,
|
||||
} from '../../AccessToken.permissions'
|
||||
import type { TokenAccessEvaluation } from '../../AccessToken.roles'
|
||||
import { PermissionRow } from './PermissionRow'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
interface PermissionsAccordionProps {
|
||||
selection: PermissionSelection
|
||||
onChange: (key: string, mode: PermissionMode) => void
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
access?: TokenAccessEvaluation
|
||||
}
|
||||
|
||||
export const PermissionsAccordion = ({
|
||||
selection,
|
||||
onChange,
|
||||
permissionScopeMap,
|
||||
access,
|
||||
}: PermissionsAccordionProps) => {
|
||||
const [openCategories, setOpenCategories] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div className="space-y-3 px-5 sm:px-6 py-6">
|
||||
<div>
|
||||
<h3 className="text-sm text-foreground">Permissions</h3>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Grant the minimum access this token needs. Everything defaults to None. Permissions follow
|
||||
your role in the organizations and projects you're a member of — see{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
|
||||
access control
|
||||
</InlineLink>{' '}
|
||||
for how roles work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={openCategories}
|
||||
onValueChange={setOpenCategories}
|
||||
className="mt-2"
|
||||
>
|
||||
{PERMISSION_CATALOG_BY_CATEGORY.map((category, index) => {
|
||||
const configuredCount = countConfiguredInCategory(selection, category.key)
|
||||
return (
|
||||
<AccordionItem
|
||||
key={category.key}
|
||||
value={category.key}
|
||||
className={cn('border', {
|
||||
'border-b-0': index < PERMISSION_CATALOG_BY_CATEGORY.length - 1,
|
||||
'rounded-t-md': index === 0,
|
||||
'rounded-b-md': index === PERMISSION_CATALOG_BY_CATEGORY.length - 1,
|
||||
})}
|
||||
>
|
||||
<AccordionTrigger className="bg-surface-300 first:rounded-t last:rounded-b px-4 py-3 hover:no-underline transition">
|
||||
<div className="flex flex-1 items-center justify-between gap-2 pr-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{category.name}</span>
|
||||
<span className="text-xs text-foreground-lighter">{category.description}</span>
|
||||
</div>
|
||||
{configuredCount > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{configuredCount} configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="*:pb-0">
|
||||
<div className="divide-y first:border-t">
|
||||
{category.entries.map((entry) => (
|
||||
<div className="px-4" key={entry.key}>
|
||||
<PermissionRow
|
||||
entry={entry}
|
||||
mode={selection[entry.key] ?? 'none'}
|
||||
onChange={(mode) => onChange(entry.key, mode)}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
entryAccess={access?.entries[entry.key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import { FormControl, FormField } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { ResourceAccessProps } from './ResourceAccess.types'
|
||||
import { ResourceMultiSelector } from './ResourceMultiSelector'
|
||||
import { ResourceOption } from './ResourceOption'
|
||||
import { useOrgAndProjectData } from '@/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
{
|
||||
value: 'all-orgs' as const,
|
||||
label: 'Everything',
|
||||
description: 'Access to all projects across all organizations you have access to.',
|
||||
},
|
||||
{
|
||||
value: 'selected-orgs' as const,
|
||||
label: 'Selected orgs',
|
||||
description: 'Access only to the organizations you have specified.',
|
||||
},
|
||||
{
|
||||
value: 'selected-projects' as const,
|
||||
label: 'Selected projects',
|
||||
description: 'Access only to the projects you have specified.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ResourceAccess = ({ control, resourceAccess, setValue }: ResourceAccessProps) => {
|
||||
const { profile } = useProfile()
|
||||
|
||||
const { organizations, projects, isLoadingOrgs, isLoadingProjects } = useOrgAndProjectData({
|
||||
enabled: !!profile,
|
||||
})
|
||||
|
||||
const handleResourceAccessChange = (newValue: string, currentValue: string) => {
|
||||
if (newValue !== currentValue) {
|
||||
setValue('selectedOrganizations', [])
|
||||
setValue('selectedProjects', [])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<FormField
|
||||
key="resourceAccess"
|
||||
name="resourceAccess"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="resourceAccess" label="Resource access">
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
<fieldset className="flex gap-3">
|
||||
<legend className="sr-only">Resource access level</legend>
|
||||
{RESOURCE_OPTIONS.map((option) => (
|
||||
<ResourceOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
isSelected={field.value === option.value}
|
||||
onChange={() => {
|
||||
handleResourceAccessChange(option.value, field.value)
|
||||
field.onChange(option.value)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<p className="text-foreground-light text-sm">
|
||||
{RESOURCE_OPTIONS.find((opt) => opt.value === field.value)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resourceAccess === 'selected-orgs' && (
|
||||
<FormField
|
||||
key="selectedOrganizations"
|
||||
name="selectedOrganizations"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ResourceMultiSelector
|
||||
field={field}
|
||||
items={organizations.map((org) => ({ id: org.slug, name: org.name }))}
|
||||
isLoading={isLoadingOrgs}
|
||||
fieldName="selectedOrganizations"
|
||||
label="Select organizations"
|
||||
loadingMessage="Loading organizations..."
|
||||
emptyMessage="No organizations available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceAccess === 'selected-projects' && (
|
||||
<FormField
|
||||
key="selectedProjects"
|
||||
name="selectedProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ResourceMultiSelector
|
||||
field={field}
|
||||
items={projects.map((project) => ({ id: project.ref, name: project.name }))}
|
||||
isLoading={isLoadingProjects}
|
||||
fieldName="selectedProjects"
|
||||
label="Select projects"
|
||||
loadingMessage="Loading projects..."
|
||||
emptyMessage="No projects available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form'
|
||||
|
||||
import { TokenFormValues } from '@/components/interfaces/Account/AccessTokens/AccessToken.schemas'
|
||||
|
||||
export interface ResourceAccessProps {
|
||||
control: Control<TokenFormValues>
|
||||
resourceAccess: string
|
||||
setValue: UseFormSetValue<TokenFormValues>
|
||||
}
|
||||
|
||||
export interface ResourceItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ResourceMultiSelectorProps {
|
||||
field: ControllerRenderProps<any, any>
|
||||
items: ResourceItem[]
|
||||
isLoading: boolean
|
||||
fieldName: string
|
||||
label: string
|
||||
loadingMessage: string
|
||||
emptyMessage: string
|
||||
}
|
||||
|
||||
export interface ResourceOptionProps {
|
||||
value: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onChange: () => void
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { FormControl } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { ResourceMultiSelectorProps } from './ResourceAccess.types'
|
||||
|
||||
export const ResourceMultiSelector = ({
|
||||
field,
|
||||
items,
|
||||
isLoading,
|
||||
fieldName,
|
||||
label,
|
||||
loadingMessage,
|
||||
emptyMessage,
|
||||
}: ResourceMultiSelectorProps) => {
|
||||
const idToName = useMemo(() => new Map(items.map((item) => [item.id, item.name])), [items])
|
||||
|
||||
const displayValues = (field.value || []).map((id: string) => idToName.get(id) || id)
|
||||
|
||||
const handleValuesChange = (names: string[]) => {
|
||||
const ids = names
|
||||
.map((name) => {
|
||||
for (const [id, itemName] of idToName.entries()) {
|
||||
if (itemName === name) return id
|
||||
}
|
||||
return name
|
||||
})
|
||||
.filter(Boolean)
|
||||
field.onChange(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItemLayout name={fieldName} label={label}>
|
||||
<FormControl className="overflow-visible">
|
||||
<MultiSelector values={displayValues} onValuesChange={handleValuesChange}>
|
||||
<MultiSelectorTrigger
|
||||
deletableBadge
|
||||
showIcon={false}
|
||||
mode="inline-combobox"
|
||||
label={label}
|
||||
badgeLimit="wrap"
|
||||
/>
|
||||
<MultiSelectorContent className="z-50">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-foreground-light">{loadingMessage}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-foreground-light">{emptyMessage}</div>
|
||||
) : (
|
||||
<MultiSelectorList>
|
||||
{items.map((item) => (
|
||||
<MultiSelectorItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
)}
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { Box, Check } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export const ResourceOption = ({
|
||||
value,
|
||||
label,
|
||||
isSelected,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onChange: () => void
|
||||
}) => (
|
||||
<label
|
||||
className={cn(
|
||||
'border border-default rounded-md bg-surface-200 hover:bg-overlay-hover hover:border-control px-4 py-3 cursor-pointer transition-colors flex-1 flex flex-col',
|
||||
isSelected && 'border-foreground-muted hover:border-foreground-muted bg-surface-300'
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<Box size={16} className="text-foreground-light" />
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center p-0.5 bg-foreground text-background rounded-full">
|
||||
<Check size={12} strokeWidth="4" className="text-background" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-sm', isSelected ? 'text-foreground' : 'text-foreground-light')}>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="radio"
|
||||
name="resourceAccess"
|
||||
value={value}
|
||||
checked={isSelected}
|
||||
onChange={onChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
MOCK_ORG,
|
||||
MOCK_PROJECT,
|
||||
mockPermissionsApi,
|
||||
mockScopedTokenEnvironment,
|
||||
readonlyRows,
|
||||
} from '../../AccessToken.fixtures'
|
||||
import { NewScopedTokenSheet } from '../NewScopedTokenSheet'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
|
||||
|
||||
// Disabling orgs for project-scoped members reads /platform/profile/permissions, which only
|
||||
// fires on the platform for a logged-in user — neither is true in the default test environment.
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('common')
|
||||
return { ...actual, useIsLoggedIn: () => true }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return { ...actual, IS_PLATFORM: true }
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
describe('ResourceAccessStep organization selector', () => {
|
||||
beforeEach(() => {
|
||||
mockScopedTokenEnvironment()
|
||||
})
|
||||
|
||||
const openOrganizationSelector = async () => {
|
||||
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
|
||||
profileContext: createMockProfileContext(),
|
||||
})
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
|
||||
}
|
||||
|
||||
test('disables organizations where the user only has project-level access', async () => {
|
||||
mockPermissionsApi(readonlyRows(MOCK_ORG.slug, [MOCK_PROJECT.ref]))
|
||||
await openOrganizationSelector()
|
||||
|
||||
const option = await screen.findByRole('option', { name: new RegExp(MOCK_ORG.name) })
|
||||
expect(option).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Your access is limited to specific projects. Create a project-scoped token instead.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('keeps organizations selectable for members with org-wide access', async () => {
|
||||
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
|
||||
await openOrganizationSelector()
|
||||
|
||||
const option = await screen.findByRole('option', { name: new RegExp(MOCK_ORG.name) })
|
||||
expect(option).not.toHaveAttribute('aria-disabled', 'true')
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Your access is limited to specific projects. Create a project-scoped token instead.'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Control, UseFormSetValue } from 'react-hook-form'
|
||||
import {
|
||||
Badge,
|
||||
FormControl,
|
||||
FormField,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
useWatch,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorInput,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import type { ResourceAccessMode } from '../../AccessToken.permissions'
|
||||
import { getIsProjectScopedOnly } from '../../AccessToken.roles'
|
||||
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
|
||||
import type { TokenFormValues } from './NewScopedTokenForm.utils'
|
||||
import { InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
|
||||
import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query'
|
||||
import { Organization } from '@/types'
|
||||
|
||||
interface ResourceAccessStepProps {
|
||||
control: Control<TokenFormValues>
|
||||
setValue: UseFormSetValue<TokenFormValues>
|
||||
|
||||
/** Switches the form into legacy (account-wide) token mode. */
|
||||
onSelectLegacyToken: () => void
|
||||
}
|
||||
|
||||
const CARD_OPTIONS: {
|
||||
value: Exclude<ResourceAccessMode, 'account'>
|
||||
name: string
|
||||
description: string
|
||||
recommended?: boolean
|
||||
}[] = [
|
||||
{
|
||||
value: 'project',
|
||||
name: 'Project',
|
||||
description: 'Access select projects in a single organization.',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: 'organization',
|
||||
name: 'Organization',
|
||||
description: 'Access all projects in select organizations.',
|
||||
},
|
||||
]
|
||||
|
||||
export const ResourceAccessStep = ({
|
||||
control,
|
||||
setValue,
|
||||
onSelectLegacyToken,
|
||||
}: ResourceAccessStepProps) => {
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
const organizationsBySlug = useMemo(
|
||||
() =>
|
||||
organizations.reduce(
|
||||
(acc, organization) => {
|
||||
acc[organization.slug] = organization
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Organization>
|
||||
),
|
||||
[organizations]
|
||||
)
|
||||
const projectsByRef = useMemo(
|
||||
() =>
|
||||
projects.reduce(
|
||||
(acc, project) => {
|
||||
acc[project.ref] = project
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ProjectInfoInfinite>
|
||||
),
|
||||
[projects]
|
||||
)
|
||||
|
||||
const resourceAccess = useWatch({ control, name: 'resourceAccess' })
|
||||
const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
|
||||
|
||||
// Users invited to specific projects (rather than the whole org) can't select that org for an
|
||||
// org-wide token. Skipped while permissions are still loading so nothing gets disabled by
|
||||
// mistake. The project list itself needs no permission filter — /platform/projects is already
|
||||
// scoped server-side to what the user can access.
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const projectScopedOrgSlugs = useMemo(() => {
|
||||
if (permissions === undefined) return new Set<string>()
|
||||
return new Set(
|
||||
organizations
|
||||
.map((org) => org.slug)
|
||||
.filter((slug) => getIsProjectScopedOnly(permissions, slug))
|
||||
)
|
||||
}, [permissions, organizations])
|
||||
|
||||
const projectsForOrg = useMemo(
|
||||
() => projects.filter((project) => organizationSlugs.includes(project.organization_slug)),
|
||||
[projects, organizationSlugs]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name="resourceAccess"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Resource access"
|
||||
description={
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Need a token with full access to your account or one for the Supabase MCP server?{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={InlineLinkClassName}
|
||||
onClick={onSelectLegacyToken}
|
||||
>
|
||||
Create legacy token
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
id="resourceAccess"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupStacked
|
||||
value={resourceAccess}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
// Reset dependent selections when switching modes.
|
||||
setValue('projectRefs', [])
|
||||
}}
|
||||
>
|
||||
{CARD_OPTIONS.map((option) => (
|
||||
<RadioGroupStackedItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
value={option.value}
|
||||
className="w-full"
|
||||
label={
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
{option.recommended && <Badge variant="success">Recommended</Badge>}
|
||||
</div>
|
||||
<span className="text-foreground-light">{option.description}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</RadioGroupStacked>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resourceAccess === 'project' && (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="organizationSlugs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={<span className="sr-only">Organization</span>}
|
||||
id="organizationSlugs"
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value.length > 0 ? field.value[0] : ''}
|
||||
onValueChange={(value) => {
|
||||
field.onChange([value])
|
||||
setValue('projectRefs', [])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="organizationSlugs" ref={field.ref}>
|
||||
<SelectValue placeholder="Select an organization" asChild>
|
||||
<span>{organizationsBySlug[field.value[0]]?.name}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((org) => (
|
||||
<SelectItem key={org.slug} value={org.slug}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="projectRefs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={<span className="sr-only">Projects</span>}
|
||||
id="projectRefs"
|
||||
>
|
||||
<MultiSelector
|
||||
onValuesChange={field.onChange}
|
||||
values={field.value}
|
||||
disabled={organizationSlugs.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
id="projectRefs"
|
||||
mode="combobox"
|
||||
label={
|
||||
organizationSlugs.length > 0
|
||||
? 'Select projects'
|
||||
: 'Select an organization first'
|
||||
}
|
||||
badgeLimit="wrap"
|
||||
showIcon={true}
|
||||
deletableBadge
|
||||
ref={field.ref}
|
||||
renderValue={(value) => projectsByRef[value]?.name}
|
||||
className="min-w-auto"
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorInput placeholder="Search projects" showResetIcon />
|
||||
<MultiSelectorList>
|
||||
{projectsForOrg.map((project) => (
|
||||
<MultiSelectorItem key={project.ref} value={project.ref}>
|
||||
{project.name}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{resourceAccess === 'organization' && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="organizationSlugs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label={<span className="sr-only">Organizations</span>}
|
||||
id="organizationSlugs"
|
||||
>
|
||||
<MultiSelector onValuesChange={field.onChange} values={field.value}>
|
||||
<MultiSelectorTrigger
|
||||
id="organizationSlugs"
|
||||
mode="combobox"
|
||||
label="Select organizations"
|
||||
badgeLimit="wrap"
|
||||
showIcon={true}
|
||||
deletableBadge
|
||||
className="w-full"
|
||||
ref={field.ref}
|
||||
renderValue={(value) => organizationsBySlug[value]?.name}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorInput placeholder="Search organizations" showResetIcon />
|
||||
<MultiSelectorList>
|
||||
{organizations.map((organization) => {
|
||||
const isProjectScopedOnly = projectScopedOrgSlugs.has(organization.slug)
|
||||
return (
|
||||
<MultiSelectorItem
|
||||
key={organization.slug}
|
||||
value={organization.slug}
|
||||
disabled={isProjectScopedOnly}
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span>{organization.name}</span>
|
||||
{isProjectScopedOnly && (
|
||||
<span className="text-foreground-lighter">
|
||||
Your access is limited to specific projects. Create a project-scoped
|
||||
token instead.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</MultiSelectorItem>
|
||||
)
|
||||
})}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import {
|
||||
RISK_LEVEL_LABEL,
|
||||
type PermissionCatalogEntry,
|
||||
type RiskLevel,
|
||||
} from '../../AccessToken.permissions'
|
||||
import {
|
||||
getMcpToolsForScopes,
|
||||
PermissionScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const RISK_VARIANT: Record<RiskLevel, 'success' | 'warning' | 'destructive'> = {
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'destructive',
|
||||
}
|
||||
|
||||
interface RiskMarkerProps {
|
||||
entry: PermissionCatalogEntry
|
||||
/** When false, renders the dot + label without the explanatory tooltip (used in the review list). */
|
||||
withTooltip?: boolean
|
||||
className?: string
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
export const RiskMarker = ({
|
||||
entry,
|
||||
withTooltip = true,
|
||||
className,
|
||||
permissionScopeMap,
|
||||
}: RiskMarkerProps) => {
|
||||
const marker = (
|
||||
<Badge
|
||||
variant={RISK_VARIANT[entry.risk]}
|
||||
className={cn(withTooltip && 'cursor-help', className)}
|
||||
>
|
||||
{RISK_LEVEL_LABEL[entry.risk]}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
if (!withTooltip) return marker
|
||||
|
||||
const mcpTools = getMcpToolsForScopes({
|
||||
scopeIds: [...entry.readScopes, ...entry.writeScopes],
|
||||
permissionScopeMap,
|
||||
})
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>{marker}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" className="w-72 space-y-2 p-3">
|
||||
<Badge variant={RISK_VARIANT[entry.risk]}>{RISK_LEVEL_LABEL[entry.risk]}</Badge>
|
||||
<p className="text-xs text-foreground-light">{entry.riskReason}</p>
|
||||
{(entry.allowsRead.length > 0 || entry.allowsWrite.length > 0) && (
|
||||
<div className="flex flex-col gap-5 mt-5">
|
||||
{entry.allowsRead.length > 0 && (
|
||||
<span className="text-xs text-foreground-light">
|
||||
<h3 className="text-foreground-muted text-[11px] uppercase font-mono tracking-wide font-normal mb-0.5">
|
||||
Read:{' '}
|
||||
</h3>
|
||||
{entry.allowsRead.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{entry.allowsWrite.length > 0 && (
|
||||
<span className="text-xs text-foreground-light">
|
||||
<h3 className="text-foreground-muted text-[11px] uppercase font-mono tracking-wide font-normal mb-0.5">
|
||||
Write:{' '}
|
||||
</h3>
|
||||
{entry.allowsWrite.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mcpTools.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{/* getMcpToolsForScopes is associative, not conjunctive: these scopes contribute to
|
||||
the listed tools, but a tool may need scopes from other capabilities too — the
|
||||
review step's enabled-tools list is the authoritative view. Keep this heading
|
||||
distinct from the review step's "MCP tools". */}
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
Related MCP tools
|
||||
</p>
|
||||
<p className="font-mono text-xs text-foreground-light">{mcpTools.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface StepIndicatorProps {
|
||||
/** 1-indexed current step. */
|
||||
step: number
|
||||
total: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const StepIndicator = ({ step, total, label }: StepIndicatorProps) => (
|
||||
<div className="flex items-center gap-2 text-xs text-foreground-light">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: total }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full transition-colors',
|
||||
index < step ? 'bg-primary' : 'bg-border-stronger'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-foreground-lighter">
|
||||
<span className="text-foreground">
|
||||
Step {step} of {total}
|
||||
</span>{' '}
|
||||
· {label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
import dayjs from 'dayjs'
|
||||
import type { Control, UseFormSetValue } from 'react-hook-form'
|
||||
import {
|
||||
Badge,
|
||||
Calendar,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
useWatch,
|
||||
} from 'ui'
|
||||
import {
|
||||
DatePicker,
|
||||
DatePickerButton,
|
||||
DatePickerContent,
|
||||
DatePickerTrigger,
|
||||
} from 'ui-patterns/DatePicker'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { getMaxCustomExpiryDate } from '../../AccessToken.utils'
|
||||
import {
|
||||
EXPIRY_OPTIONS,
|
||||
getDefaultCustomExpiryDate,
|
||||
type TokenFormValues,
|
||||
} from './NewScopedTokenForm.utils'
|
||||
|
||||
interface TokenDetailsProps {
|
||||
control: Control<TokenFormValues>
|
||||
setValue: UseFormSetValue<TokenFormValues>
|
||||
}
|
||||
|
||||
export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => {
|
||||
const customExpiryDate = useWatch({ control, name: 'customExpiryDate' })
|
||||
const maxExpiryDate = getMaxCustomExpiryDate().toDate()
|
||||
|
||||
const handleExpiryChange = (value: string) => {
|
||||
setValue('expiresAt', value as TokenFormValues['expiresAt'], { shouldValidate: true })
|
||||
if (value === 'custom') {
|
||||
if (!customExpiryDate) {
|
||||
const iso = getDefaultCustomExpiryDate()
|
||||
setValue('customExpiryDate', iso, { shouldValidate: true })
|
||||
}
|
||||
} else {
|
||||
setValue('customExpiryDate', undefined, { shouldValidate: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4 px-5 sm:px-6 py-6">
|
||||
<h3 className="text-sm text-foreground sr-only">Token details</h3>
|
||||
|
||||
<FormField
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name" layout="flex-row-reverse">
|
||||
<FormControl>
|
||||
<Input id="tokenName" {...field} placeholder="e.g. CI deploy token" />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key="expiresAt"
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="expiresAt" label="Expires in" layout="flex-row-reverse">
|
||||
<div className="flex gap-2 w-full">
|
||||
<FormControl className="grow">
|
||||
<Select value={field.value} onValueChange={handleExpiryChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an expiry" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
{option.label}
|
||||
{option.recommended && <Badge variant="success">Recommended</Badge>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{field.value === 'custom' && (
|
||||
<FormField
|
||||
key="customExpiryDate"
|
||||
name="customExpiryDate"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => {
|
||||
const value = field.value ? new Date(field.value) : undefined
|
||||
return (
|
||||
<div className="shrink">
|
||||
<DatePicker>
|
||||
<DatePickerTrigger asChild>
|
||||
<DatePickerButton ref={field.ref} block isInvalid={fieldState.invalid}>
|
||||
{value ? dayjs(value).format('DD MMM, YYYY') : 'Pick a date'}
|
||||
</DatePickerButton>
|
||||
</DatePickerTrigger>
|
||||
<DatePickerContent align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(date) =>
|
||||
field.onChange(
|
||||
date ? dayjs(date).endOf('day').toISOString() : undefined
|
||||
)
|
||||
}
|
||||
initialFocus
|
||||
startMonth={dayjs().startOf('month').toDate()}
|
||||
endMonth={maxExpiryDate}
|
||||
disabled={{
|
||||
before: dayjs().startOf('day').toDate(),
|
||||
after: maxExpiryDate,
|
||||
}}
|
||||
/>
|
||||
</DatePickerContent>
|
||||
</DatePicker>
|
||||
<FormMessage />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
|
||||
// Temporary warning: scoped tokens can't be used with the Supabase MCP server yet.
|
||||
// Tracked by Linear DESIGN-479, blocked on the AI-1025 FGA guard migration.
|
||||
// Remove once that lands: delete this module, then the two call sites (NewScopedTokenFormReview, ViewTokenSheet).
|
||||
|
||||
export const MCP_UNSUPPORTED_WARNING_TITLE =
|
||||
"Scoped tokens don't currently work with the Supabase MCP server"
|
||||
export const MCP_UNSUPPORTED_WARNING_DESCRIPTION = 'Support for scoped tokens is coming soon.'
|
||||
|
||||
interface McpUnsupportedWarningProps {
|
||||
onSelectLegacyToken?: () => void
|
||||
}
|
||||
|
||||
export const McpUnsupportedWarning = ({ onSelectLegacyToken }: McpUnsupportedWarningProps) => (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={MCP_UNSUPPORTED_WARNING_TITLE}
|
||||
description={
|
||||
onSelectLegacyToken ? (
|
||||
<p>
|
||||
{MCP_UNSUPPORTED_WARNING_DESCRIPTION} If you need a full-access token for the MCP server,{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={InlineLinkClassName}
|
||||
onClick={onSelectLegacyToken}
|
||||
tabIndex={0}
|
||||
>
|
||||
create a legacy token
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
MCP_UNSUPPORTED_WARNING_DESCRIPTION
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { NewScopedTokenSheet } from './NewScopedTokenSheet'
|
||||
import { type NewScopedAccessToken } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation'
|
||||
|
||||
export interface NewScopedTokenButtonProps {
|
||||
onCreateToken: (token: NewScopedAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenButton = ({ onCreateToken }: NewScopedTokenButtonProps) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
|
||||
<NewScopedTokenSheet
|
||||
visible={visible}
|
||||
onOpenChange={setVisible}
|
||||
tokenScope={undefined}
|
||||
onCreateToken={onCreateToken}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { NewScopedTokenSheet } from './NewScopedTokenSheet'
|
||||
import type { ProfileContextType } from '@/lib/profile'
|
||||
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
type OrganizationResponse = components['schemas']['OrganizationResponse']
|
||||
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
|
||||
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
|
||||
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']
|
||||
|
||||
const user = userEvent.setup({
|
||||
writeToClipboard: true,
|
||||
})
|
||||
|
||||
const PROFILE_CONTEXT: ProfileContextType = {
|
||||
profile: {
|
||||
id: 1,
|
||||
auth0_id: 'auth0|test',
|
||||
gotrue_id: 'gotrue-test',
|
||||
username: 'testuser',
|
||||
primary_email: 'test@example.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
mobile: null,
|
||||
is_alpha_user: false,
|
||||
is_sso_user: false,
|
||||
disabled_features: [],
|
||||
free_project_limit: null,
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
}
|
||||
|
||||
const mockOrganizations = () =>
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () =>
|
||||
HttpResponse.json<OrganizationResponse[]>([
|
||||
createMockOrganizationResponse({ slug: 'acme-prod', name: 'Acme Production' }),
|
||||
]),
|
||||
})
|
||||
|
||||
const mockProjects = () =>
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects',
|
||||
response: () =>
|
||||
HttpResponse.json<ProjectsResponse>({
|
||||
pagination: { count: 1, limit: 100, offset: 0 },
|
||||
projects: [
|
||||
{
|
||||
...createMockProject({
|
||||
id: 1,
|
||||
ref: 'project-1',
|
||||
name: 'Project 1',
|
||||
organization_id: 1,
|
||||
}),
|
||||
organization_slug: 'acme-prod',
|
||||
preview_branch_refs: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const mockPermissionsMap = () =>
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
// @ts-expect-error Studio API is missing from types
|
||||
path: '/scoped-access-token-permissions',
|
||||
response: () =>
|
||||
HttpResponse.json({
|
||||
scopes: {},
|
||||
endpoints: {},
|
||||
mcp_tools: {},
|
||||
}),
|
||||
})
|
||||
|
||||
const mockCreateToken = () =>
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/profile/scoped-access-tokens',
|
||||
response: () =>
|
||||
HttpResponse.json<CreateTokenResponse>({
|
||||
created_at: '',
|
||||
expires_at: null,
|
||||
id: 'plop',
|
||||
last_used_at: null,
|
||||
name: 'test',
|
||||
token: 'a_token_value',
|
||||
token_alias: '',
|
||||
permissions: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const mockCreateClassicToken = () =>
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/profile/access-tokens',
|
||||
response: () =>
|
||||
HttpResponse.json<CreateClassicTokenResponse>({
|
||||
created_at: '',
|
||||
expires_at: null,
|
||||
id: 1,
|
||||
last_used_at: null,
|
||||
name: 'test',
|
||||
token: 'a_classic_token_value',
|
||||
token_alias: '',
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Permission categories render collapsed, so a category has to be expanded before its
|
||||
* rows are in the DOM.
|
||||
*/
|
||||
const expandPermissionCategory = async (name: string) =>
|
||||
fireEvent.click(await screen.findByRole('button', { name: new RegExp(`^${name}`) }))
|
||||
|
||||
describe('NewScopedTokenSheet', () => {
|
||||
const renderSheet = () =>
|
||||
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
|
||||
profileContext: PROFILE_CONTEXT,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockPermissionsMap()
|
||||
mockOrganizations()
|
||||
mockProjects()
|
||||
mockCreateToken()
|
||||
mockCreateClassicToken()
|
||||
})
|
||||
test('requires a token name', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('Please enter a name for the token'))
|
||||
})
|
||||
// Project scope tests
|
||||
test('requires an organization when scope is Project', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('Please select an organization to continue.'))
|
||||
})
|
||||
test('requires a project when scope is Project', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('Please select a project to continue.'))
|
||||
})
|
||||
test('requires permissions when scope is Project', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Project 1' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
|
||||
})
|
||||
test('creates the token when scope is Project', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Project 1' }))
|
||||
await expandPermissionCategory('Project')
|
||||
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Read' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
// Review screen
|
||||
await screen.findByText('Low Risk')
|
||||
await screen.findByText('Single-project read-only access')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create token' }))
|
||||
// If we can click this checkbox, the token was created
|
||||
// Must be a real click, which focuses the button: nothing holds focus once the form
|
||||
// unmounts, and copyToClipboard bails out when the document has no focus
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy' }))
|
||||
await waitFor(async () =>
|
||||
expect(await window.navigator.clipboard.readText()).toEqual('a_token_value')
|
||||
)
|
||||
fireEvent.click(await screen.findByLabelText('I have copied the key and stored it securely'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Done' }))
|
||||
// Dialog has been closed
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
// Organization scope tests
|
||||
test('requires an organization when scope is Organization', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('Please select an organization to continue.'))
|
||||
})
|
||||
test('requires permissions when scope is Organization', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
expect(await screen.findByText('No permissions selected', { selector: '[role="alert"] *' }))
|
||||
})
|
||||
test('creates the token when scope is Organization', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
await expandPermissionCategory('Project')
|
||||
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Read' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
// Review screen
|
||||
await screen.findByText('Low Risk')
|
||||
await screen.findByText('Organization-wide read-only access')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create token' }))
|
||||
// If we can click this checkbox, the token was created
|
||||
// Must be a real click, which focuses the button: nothing holds focus once the form
|
||||
// unmounts, and copyToClipboard bails out when the document has no focus
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy' }))
|
||||
await waitFor(async () =>
|
||||
expect(await window.navigator.clipboard.readText()).toEqual('a_token_value')
|
||||
)
|
||||
fireEvent.click(await screen.findByLabelText('I have copied the key and stored it securely'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Done' }))
|
||||
// Dialog has been closed
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
test('opens the experimental API dialog from the dropdown', async () => {
|
||||
renderSheet()
|
||||
await user.click(await screen.findByRole('button', { name: 'Choose token scope' }))
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: 'Generate token for experimental API' })
|
||||
)
|
||||
// The experimental API dialog is open
|
||||
await screen.findByText(
|
||||
'The experimental API provides additional endpoints which allows you to manage your organizations and projects.'
|
||||
)
|
||||
})
|
||||
|
||||
// Classic (account-wide) token tests
|
||||
test('switches to the classic token form via the legacy link', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
// Trigger a resource validation error before entering legacy mode
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
await screen.findByText('Please select an organization to continue.')
|
||||
await user.click(await screen.findByText('Create legacy token'))
|
||||
// The classic warning replaces resource access and permissions
|
||||
await screen.findByText('Access tokens can be used to control your whole account')
|
||||
expect(screen.queryByRole('button', { name: 'Review access' })).toBeNull()
|
||||
await screen.findByRole('button', { name: 'Generate token' })
|
||||
// Switching back restores the scoped form, without resurfacing the stale resource error
|
||||
await user.click(await screen.findByText('Create scoped token'))
|
||||
await screen.findByRole('button', { name: 'Review access' })
|
||||
expect(screen.queryByText('Access tokens can be used to control your whole account')).toBeNull()
|
||||
expect(screen.queryByText('Please select an organization to continue.')).toBeNull()
|
||||
})
|
||||
test('switches to the classic token form from the review step MCP notice', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
|
||||
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Project 1' }))
|
||||
await expandPermissionCategory('Project')
|
||||
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Read' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
|
||||
// The review step's own legacy-token link switches back into legacy mode
|
||||
await screen.findByText("Scoped tokens don't currently work with the Supabase MCP server")
|
||||
await user.click(await screen.findByText('create a legacy token'))
|
||||
await screen.findByText('Access tokens can be used to control your whole account')
|
||||
await screen.findByRole('button', { name: 'Generate token' })
|
||||
expect(screen.queryByRole('button', { name: 'Review access' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Create token' })).toBeNull()
|
||||
})
|
||||
test('creates a classic token via the legacy link', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.type(await screen.findByLabelText('Name'), 'test')
|
||||
await user.click(await screen.findByText('Create legacy token'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate token' }))
|
||||
// If we can click this button, the token was created
|
||||
// Must be a real click, which focuses the button: nothing holds focus once the form
|
||||
// unmounts, and copyToClipboard bails out when the document has no focus
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy' }))
|
||||
await waitFor(async () =>
|
||||
expect(await window.navigator.clipboard.readText()).toEqual('a_classic_token_value')
|
||||
)
|
||||
fireEvent.click(await screen.findByLabelText('I have copied the key and stored it securely'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Done' }))
|
||||
// Dialog has been closed
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
})
|
||||
+91
-274
@@ -1,327 +1,144 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import dayjs from 'dayjs'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useForm, useWatch, type SubmitHandler } from 'react-hook-form'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import {
|
||||
CUSTOM_EXPIRY_VALUE,
|
||||
EXPIRES_AT_OPTIONS,
|
||||
type ScopedAccessTokenPermission,
|
||||
} from '../AccessToken.constants'
|
||||
import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas'
|
||||
import { getExpirationDate, mapPermissionToFGA } from '../AccessToken.utils'
|
||||
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
|
||||
import { BasicInfo } from './Form/BasicInfo'
|
||||
import { Permissions } from './Form/Permissions/Permissions'
|
||||
import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess'
|
||||
import { selectionToScopes } from '../AccessToken.permissions'
|
||||
import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown'
|
||||
import { NewScopedTokenForm } from './Form/NewScopedTokenForm'
|
||||
import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils'
|
||||
import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess'
|
||||
import {
|
||||
useAccessTokenCreateMutation,
|
||||
type NewAccessToken,
|
||||
} from '@/data/access-tokens/access-tokens-create-mutation'
|
||||
import {
|
||||
useScopedAccessTokenCreateMutation,
|
||||
type NewScopedAccessToken,
|
||||
type ScopedAccessTokenCreateVariables,
|
||||
} from '@/data/scoped-access-tokens/scoped-access-token-create-mutation'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export interface NewScopedTokenSheetProps {
|
||||
visible: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tokenScope: 'V0' | undefined
|
||||
onCreateToken: (token: NewScopedAccessToken) => void
|
||||
interface NewScopedTokenSheetProps {
|
||||
/** Called with the created token when one is generated through the experimental API dialog. */
|
||||
onCreateExperimentalToken: (token: NewAccessToken) => void
|
||||
}
|
||||
|
||||
export const NewScopedTokenSheet = ({
|
||||
visible,
|
||||
onOpenChange,
|
||||
tokenScope,
|
||||
onCreateToken,
|
||||
}: NewScopedTokenSheetProps) => {
|
||||
const [resourceSearchOpen, setResourceSearchOpen] = useState(false)
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
|
||||
const form = useForm<TokenFormValues>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: {
|
||||
tokenName: '',
|
||||
expiresAt: EXPIRES_AT_OPTIONS['month'].value,
|
||||
customExpiryDate: undefined,
|
||||
resourceAccess: 'all-orgs',
|
||||
selectedOrganizations: [],
|
||||
selectedProjects: [],
|
||||
permissionRows: [],
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedTokenSheetProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const track = useTrack()
|
||||
const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation()
|
||||
const { mutate: createToken, isPending: isCreatingScopedToken } =
|
||||
useScopedAccessTokenCreateMutation()
|
||||
const { mutate: createClassicToken, isPending: isCreatingClassicToken } =
|
||||
useAccessTokenCreateMutation()
|
||||
|
||||
const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' })
|
||||
const expiresAt = useWatch({ control: form.control, name: 'expiresAt' })
|
||||
const permissionRows = useWatch({ control: form.control, name: 'permissionRows' }) || []
|
||||
const [step, setStep] = useState<'form' | 'success'>('form')
|
||||
const [createdToken, setCreatedToken] = useState<
|
||||
NewScopedAccessToken | NewAccessToken | undefined
|
||||
>()
|
||||
|
||||
const onSubmit: SubmitHandler<TokenFormValues> = async (values) => {
|
||||
if (!permissionRows || permissionRows.length === 0) {
|
||||
toast.error('Please configure at least one permission.')
|
||||
const showCreatedToken = (data: NewScopedAccessToken | NewAccessToken) => {
|
||||
toast.success('Access token created successfully')
|
||||
setCreatedToken(data)
|
||||
setStep('success')
|
||||
}
|
||||
|
||||
const handleCreate = (values: TokenFormValues) => {
|
||||
const expires_at =
|
||||
values.expiresAt === 'custom' ? values.customExpiryDate : getExpiryDate(values.expiresAt)
|
||||
|
||||
// 'account' access creates a classic (account-wide) token via the legacy endpoint.
|
||||
if (values.resourceAccess === 'account') {
|
||||
createClassicToken(
|
||||
{ name: values.tokenName.trim(), expires_at },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
track('access_token_created', {
|
||||
tokenType: 'classic',
|
||||
expiryPreset: values.expiresAt,
|
||||
})
|
||||
showCreatedToken(data)
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const hasValidPermissions = permissionRows.every(
|
||||
(row) => row.resource && row.actions && row.actions.length > 0
|
||||
)
|
||||
if (!hasValidPermissions) {
|
||||
toast.error('Please ensure all permissions have both resource and action selected.')
|
||||
return
|
||||
}
|
||||
const permissions = selectionToScopes(values.permissions)
|
||||
if (permissions.length === 0) return
|
||||
|
||||
if (values.resourceAccess === 'selected-orgs') {
|
||||
const selectedOrgs = values.selectedOrganizations || []
|
||||
|
||||
if (selectedOrgs.length === 0) {
|
||||
toast.error('Please select at least one organization.')
|
||||
return
|
||||
}
|
||||
|
||||
const availableOrgSlugs = organizations.map((org) => org.slug)
|
||||
const invalidOrgs = selectedOrgs.filter((slug) => !availableOrgSlugs.includes(slug))
|
||||
|
||||
if (invalidOrgs.length > 0) {
|
||||
toast.error(
|
||||
`You don't have access to the following organization(s): ${invalidOrgs.join(', ')}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (values.resourceAccess === 'selected-projects') {
|
||||
const selectedProjects = values.selectedProjects || []
|
||||
|
||||
if (selectedProjects.length === 0) {
|
||||
toast.error('Please select at least one project.')
|
||||
return
|
||||
}
|
||||
|
||||
const availableProjectRefs = projects.map((project) => project.ref)
|
||||
const invalidProjects = selectedProjects.filter((ref) => !availableProjectRefs.includes(ref))
|
||||
|
||||
if (invalidProjects.length > 0) {
|
||||
toast.error(
|
||||
`You don't have access to the following project(s): ${invalidProjects.join(', ')}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const finalExpiresAt =
|
||||
values.expiresAt === CUSTOM_EXPIRY_VALUE
|
||||
? values.customExpiryDate
|
||||
: getExpirationDate(values.expiresAt || '')
|
||||
|
||||
const permissions = permissionRows
|
||||
.flatMap((row) => {
|
||||
const { resource, actions } = row
|
||||
return actions.flatMap((action) => mapPermissionToFGA(resource, action))
|
||||
})
|
||||
.filter(Boolean) as ScopedAccessTokenPermission[]
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
toast.error('Please configure at least one valid permission.')
|
||||
return
|
||||
}
|
||||
|
||||
const finalPayload: ScopedAccessTokenCreateVariables = {
|
||||
name: values.tokenName,
|
||||
const payload: ScopedAccessTokenCreateVariables = {
|
||||
name: values.tokenName.trim(),
|
||||
permissions,
|
||||
...(expires_at ? { expires_at } : {}),
|
||||
...(values.resourceAccess === 'project' ? { project_refs: values.projectRefs } : {}),
|
||||
...(values.resourceAccess === 'organization'
|
||||
? { organization_slugs: values.organizationSlugs }
|
||||
: {}),
|
||||
}
|
||||
|
||||
if (finalExpiresAt) {
|
||||
finalPayload.expires_at = finalExpiresAt
|
||||
}
|
||||
|
||||
if (
|
||||
values.resourceAccess === 'selected-orgs' &&
|
||||
values.selectedOrganizations &&
|
||||
values.selectedOrganizations.length > 0
|
||||
) {
|
||||
finalPayload.organization_slugs = values.selectedOrganizations
|
||||
} else if (
|
||||
values.resourceAccess === 'selected-projects' &&
|
||||
values.selectedProjects &&
|
||||
values.selectedProjects.length > 0
|
||||
) {
|
||||
finalPayload.project_refs = values.selectedProjects
|
||||
}
|
||||
|
||||
if (!finalPayload.name || finalPayload.name.trim() === '') {
|
||||
toast.error('Please enter a token name.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!finalPayload.permissions || finalPayload.permissions.length === 0) {
|
||||
toast.error('Please configure at least one permission.')
|
||||
return
|
||||
}
|
||||
|
||||
createAccessToken(finalPayload, {
|
||||
createToken(payload, {
|
||||
onSuccess: (data) => {
|
||||
track('access_token_created', {
|
||||
tokenType: 'scoped',
|
||||
expiryPreset: values.expiresAt || 'never',
|
||||
expiryPreset: values.expiresAt,
|
||||
resourceAccess: values.resourceAccess,
|
||||
permissionCount: permissions.length,
|
||||
})
|
||||
toast.success('Access token created successfully')
|
||||
onCreateToken(data)
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message && error.message.includes("don't have access")) {
|
||||
toast.error(
|
||||
`Access Error: ${error.message}. Please verify you have access to the selected resources.`
|
||||
)
|
||||
} else {
|
||||
toast.error(`Failed to create access token: ${error.message}`)
|
||||
}
|
||||
showCreatedToken(data)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset({
|
||||
tokenName: '',
|
||||
expiresAt: EXPIRES_AT_OPTIONS['month'].value,
|
||||
customExpiryDate: undefined,
|
||||
resourceAccess: 'all-orgs',
|
||||
selectedOrganizations: [],
|
||||
selectedProjects: [],
|
||||
permissionRows: [],
|
||||
})
|
||||
onOpenChange(false)
|
||||
// By default, if users created a token successfully, they can't click outside the sheet to close it
|
||||
// as we need to make sure they copied the new token first
|
||||
const handleOpenChange = (open: boolean, isSafe = false) => {
|
||||
if (open === false && step === 'success' && !isSafe) return
|
||||
setStep('form')
|
||||
setIsOpen(open)
|
||||
}
|
||||
|
||||
const handleCustomDateChange = useCallback(
|
||||
(date: { date: string } | undefined) => {
|
||||
form.setValue('customExpiryDate', date?.date, { shouldValidate: true })
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const handleCustomExpiryChange = useCallback(
|
||||
(isCustom: boolean) => {
|
||||
if (isCustom && !form.getValues('customExpiryDate')) {
|
||||
form.setValue('customExpiryDate', dayjs().endOf('day').toISOString(), {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
if (!isCustom) {
|
||||
form.setValue('customExpiryDate', undefined, { shouldValidate: true })
|
||||
}
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose()
|
||||
} else {
|
||||
onOpenChange(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<div className="flex items-center">
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" className="rounded-r-none px-3">
|
||||
Generate new token
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<ExperimentalTokenDropdown onCreateToken={onCreateExperimentalToken} />
|
||||
</div>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size="default"
|
||||
className="min-w-[600px]! flex flex-col h-full gap-0"
|
||||
className="flex h-full flex-col gap-0 sm:w-[656px] lg:w-[800px]"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</SheetTitle>
|
||||
<SheetTitle>{step === 'success' ? 'Token created' : 'Generate token'}</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
A form to generate a new scoped access token.
|
||||
Configure and create a new access token.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
|
||||
<div className="flex flex-col overflow-visible">
|
||||
{tokenScope === 'V0' && (
|
||||
<div className="px-4 sm:px-5 py-4 pb-4">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild variant="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<div className="flex flex-col gap-0 overflow-visible">
|
||||
<BasicInfo
|
||||
control={form.control}
|
||||
expirationDate={expiresAt || ''}
|
||||
onCustomDateChange={handleCustomDateChange}
|
||||
onCustomExpiryChange={handleCustomExpiryChange}
|
||||
/>
|
||||
<Separator />
|
||||
<ResourceAccess
|
||||
control={form.control}
|
||||
resourceAccess={resourceAccess}
|
||||
setValue={form.setValue}
|
||||
/>
|
||||
<Separator />
|
||||
<Permissions
|
||||
control={form.control}
|
||||
resourceSearchOpen={resourceSearchOpen}
|
||||
setResourceSearchOpen={setResourceSearchOpen}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="justify-end! w-full mt-auto py-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="default" disabled={isPending} onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={form.handleSubmit(onSubmit)} loading={isPending}>
|
||||
Generate token
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
{step === 'success' && createdToken ? (
|
||||
<NewScopedTokenSuccess
|
||||
tokenName={createdToken.name}
|
||||
tokenValue={createdToken.token}
|
||||
onClose={() => handleOpenChange(false, true)}
|
||||
/>
|
||||
) : (
|
||||
<NewScopedTokenForm
|
||||
isPending={isCreatingScopedToken || isCreatingClassicToken}
|
||||
onCreateToken={handleCreate}
|
||||
onCancel={() => handleOpenChange(false, true)}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Key, MoreVertical, Trash } from 'lucide-react'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN_SORT_VALUES,
|
||||
AccessTokenSort,
|
||||
AccessTokenSortColumn,
|
||||
} from '../AccessToken.types'
|
||||
import { filterAndSortTokens, handleSortChange } from '../AccessToken.utils'
|
||||
import { RowLoading } from '../AccessTokenTable/RowLoading'
|
||||
import { TableContainer } from '../AccessTokenTable/TableContainer'
|
||||
import { ExpiresCell, LastUsedCell, TokenNameCell } from '../AccessTokenTable/TokenCells'
|
||||
import { ViewTokenSheet } from './ViewTokenSheet'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import {
|
||||
ScopedAccessToken,
|
||||
useScopedAccessTokensQuery,
|
||||
} from '@/data/scoped-access-tokens/scoped-access-token-query'
|
||||
import { useScopedAccessTokenDeleteMutation } from '@/data/scoped-access-tokens/scoped-access-tokens-delete-mutation'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
export interface ScopedTokenListProps {
|
||||
searchString?: string
|
||||
onDeleteSuccess: (id: string | number) => void
|
||||
}
|
||||
|
||||
export const ScopedTokenList = ({ searchString = '', onDeleteSuccess }: ScopedTokenListProps) => {
|
||||
const track = useTrack()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [token, setToken] = useState<ScopedAccessToken | undefined>(undefined)
|
||||
const [viewToken, setViewToken] = useState<ScopedAccessToken | undefined>(undefined)
|
||||
const [isViewSheetOpen, setIsViewSheetOpen] = useState(false)
|
||||
const [sort, setSort] = useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral<AccessTokenSort>(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc')
|
||||
)
|
||||
|
||||
const { data: tokensData, error, isPending: isLoading, isError } = useScopedAccessTokensQuery()
|
||||
|
||||
const tokens = tokensData?.tokens
|
||||
|
||||
const { mutate: deleteToken } = useScopedAccessTokenDeleteMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
track('access_token_removed', { tokenType: 'scoped' })
|
||||
onDeleteSuccess(vars.id)
|
||||
toast.success('Successfully deleted access token')
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete access token: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const onSortChange = (column: AccessTokenSortColumn) => {
|
||||
handleSortChange(sort, column, setSort)
|
||||
}
|
||||
|
||||
const filteredTokens = useMemo(
|
||||
() => filterAndSortTokens(tokens, searchString, sort),
|
||||
[tokens, searchString, sort]
|
||||
)
|
||||
|
||||
const empty = filteredTokens?.length === 0 && !isLoading
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="p-0">
|
||||
<AlertError
|
||||
error={error}
|
||||
subject="Failed to retrieve access tokens"
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<RowLoading />
|
||||
<RowLoading />
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12">
|
||||
<p className="text-sm text-center text-foreground">No scoped access tokens found</p>
|
||||
<p className="text-sm text-center text-foreground-light">
|
||||
You do not have any scoped tokens created yet
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer sort={sort} onSortChange={onSortChange}>
|
||||
{filteredTokens?.map((x) => (
|
||||
<TableRow key={x.id}>
|
||||
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
|
||||
<LastUsedCell lastUsedAt={x.last_used_at} />
|
||||
<ExpiresCell expiresAt={x.expires_at} />
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
title="More options"
|
||||
className="w-7"
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setViewToken(x)
|
||||
setIsViewSheetOpen(true)
|
||||
}}
|
||||
>
|
||||
<Key size={12} />
|
||||
<p>View permissions</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete token</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={isOpen}
|
||||
variant="destructive"
|
||||
title="Confirm to delete"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (token) deleteToken({ id: token.id as string })
|
||||
}}
|
||||
>
|
||||
<p className="py-4 text-sm text-foreground-light">
|
||||
This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ViewTokenSheet
|
||||
visible={isViewSheetOpen}
|
||||
tokenId={viewToken ? String(viewToken.id) : undefined}
|
||||
onClose={() => {
|
||||
setIsViewSheetOpen(false)
|
||||
setViewToken(undefined)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Badge, cn } from 'ui'
|
||||
|
||||
import {
|
||||
PERMISSION_MODE_LABEL,
|
||||
RISK_DOT_CLASS,
|
||||
RISK_TONE_VARIANT,
|
||||
type OverallRisk,
|
||||
type PermissionCatalogEntry,
|
||||
type PermissionMode,
|
||||
} from '../AccessToken.permissions'
|
||||
import type { EntryAccess } from '../AccessToken.roles'
|
||||
import { ExceedsRoleBadge } from './ExceedsRoleBadge'
|
||||
|
||||
/**
|
||||
* Presentational pieces of the token view sheet's summary section.
|
||||
*/
|
||||
|
||||
interface CapabilityCategoryListProps {
|
||||
categories: {
|
||||
key: string
|
||||
name: string
|
||||
entries: { entry: PermissionCatalogEntry; mode: PermissionMode }[]
|
||||
}[]
|
||||
/** Per-entry access evaluation; entries flagged 'exceeds-role' get the warning pill. */
|
||||
accessEntries: Record<string, EntryAccess>
|
||||
}
|
||||
|
||||
export const CapabilityCategoryList = ({
|
||||
categories,
|
||||
accessEntries,
|
||||
}: CapabilityCategoryListProps) => (
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.key} className="space-y-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
{category.name}
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{category.entries.map(({ entry, mode }) => {
|
||||
const entryAccess = accessEntries[entry.key]
|
||||
return (
|
||||
<div key={entry.key} className="flex items-center justify-between gap-2 text-sm py-2">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn('h-1.5 w-1.5 shrink-0 rounded-full', RISK_DOT_CLASS[entry.risk])}
|
||||
/>
|
||||
<span className="text-foreground text-wrap">{entry.name}</span>
|
||||
{entryAccess?.status === 'exceeds-role' && (
|
||||
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
|
||||
{PERMISSION_MODE_LABEL[mode]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface RiskLevelSummaryProps {
|
||||
risk: OverallRisk
|
||||
/** True when some selected permissions exceed the owner's role, so the risk is role-capped. */
|
||||
showRoleCaveat: boolean
|
||||
}
|
||||
|
||||
export const RiskLevelSummary = ({ risk, showRoleCaveat }: RiskLevelSummaryProps) => (
|
||||
<div className="space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex">
|
||||
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
|
||||
</span>
|
||||
<span className="text-sm text-foreground leading-px">
|
||||
{risk.text.replace(`${risk.level} — `, '')}
|
||||
</span>
|
||||
</span>
|
||||
{showRoleCaveat && (
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Based on what your current role allows this token to do.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ResourceSummaryItemProps {
|
||||
label: string
|
||||
/** Mono-rendered identifier under the name — the org slug or project ref. */
|
||||
sublabel?: string
|
||||
isInaccessible?: boolean
|
||||
}
|
||||
|
||||
export const ResourceSummaryItem = ({
|
||||
label,
|
||||
sublabel,
|
||||
isInaccessible = false,
|
||||
}: ResourceSummaryItemProps) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn('text-sm', isInaccessible ? 'text-foreground-lighter' : 'text-foreground')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{sublabel !== undefined && (
|
||||
<span className="font-mono text-xs text-foreground-lighter">{sublabel}</span>
|
||||
)}
|
||||
</span>
|
||||
{isInaccessible && <Badge variant="destructive">No longer accessible</Badge>}
|
||||
</div>
|
||||
)
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
MOCK_ORG,
|
||||
mockPermissionsApi,
|
||||
mockScopedTokenEnvironment,
|
||||
ownerRows,
|
||||
readonlyRows,
|
||||
} from '../AccessToken.fixtures'
|
||||
import { ViewTokenSheet } from './ViewTokenSheet'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
|
||||
|
||||
type TokenResponse = components['schemas']['GetScopedAccessTokenResponse']
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
// The role evaluation reads /platform/profile/permissions, which only fires on the platform for a
|
||||
// logged-in user — neither is true in the default test environment.
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('common')
|
||||
return { ...actual, useIsLoggedIn: () => true }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/constants', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return { ...actual, IS_PLATFORM: true }
|
||||
})
|
||||
|
||||
const TOKEN_BASE = {
|
||||
created_at: '2026-08-01T00:00:00.000Z',
|
||||
expires_at: null,
|
||||
id: 'token-1',
|
||||
last_used_at: null,
|
||||
name: 'CI token',
|
||||
token_alias: 'sbp_test123',
|
||||
} satisfies Partial<TokenResponse>
|
||||
|
||||
const mockToken = (token: TokenResponse) =>
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/profile/scoped-access-tokens/:id',
|
||||
response: () => HttpResponse.json<TokenResponse>(token),
|
||||
})
|
||||
|
||||
describe('ViewTokenSheet', () => {
|
||||
beforeEach(() => {
|
||||
mockScopedTokenEnvironment()
|
||||
})
|
||||
|
||||
const renderSheet = () =>
|
||||
customRender(<ViewTokenSheet visible tokenId="token-1" onClose={() => {}} />, {
|
||||
profileContext: createMockProfileContext(),
|
||||
})
|
||||
|
||||
test('shows no access warnings when the role covers every permission', async () => {
|
||||
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
|
||||
mockToken({
|
||||
...TOKEN_BASE,
|
||||
scope: 'organization',
|
||||
organization_slugs: [MOCK_ORG.slug],
|
||||
permissions: ['database_read', 'database_write'],
|
||||
})
|
||||
renderSheet()
|
||||
|
||||
// Bound org resolves with its name and slug, meaning evaluation completed without warnings.
|
||||
expect(await screen.findByText(MOCK_ORG.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(MOCK_ORG.slug)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Exceeds your role')).toBeNull()
|
||||
expect(
|
||||
screen.queryByText('Some permissions exceed your current role for the selected resources')
|
||||
).toBeNull()
|
||||
expect(screen.queryByText('This token no longer has access')).toBeNull()
|
||||
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
|
||||
})
|
||||
|
||||
test('marks permissions above the current role without blocking the rest', async () => {
|
||||
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
|
||||
mockToken({
|
||||
...TOKEN_BASE,
|
||||
scope: 'organization',
|
||||
organization_slugs: [MOCK_ORG.slug],
|
||||
// database_write requires Developer; the owner of this token is Read-only.
|
||||
permissions: ['database_read', 'database_write'],
|
||||
})
|
||||
renderSheet()
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Some permissions exceed your current role for the selected resources'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Exceeds your role')).toBeInTheDocument()
|
||||
// Advisory only — the other (destructive) states must not fire.
|
||||
expect(screen.queryByText('This token no longer has access')).toBeNull()
|
||||
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
|
||||
})
|
||||
|
||||
test('reports lost access when the user was removed from every bound resource', async () => {
|
||||
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
|
||||
mockToken({
|
||||
...TOKEN_BASE,
|
||||
scope: 'organization',
|
||||
// Bound to an org the user can no longer see.
|
||||
organization_slugs: ['departed-org'],
|
||||
permissions: ['members_read'],
|
||||
})
|
||||
renderSheet()
|
||||
|
||||
expect(await screen.findByText('This token no longer has access')).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText(/You were removed from the organizations this token is bound to/)
|
||||
).toBeInTheDocument()
|
||||
// The lost resource renders as an anonymous count, never its slug.
|
||||
expect(await screen.findByText('1 organization')).toBeInTheDocument()
|
||||
expect(await screen.findByText('No longer accessible')).toBeInTheDocument()
|
||||
expect(screen.queryByText('departed-org')).toBeNull()
|
||||
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
|
||||
})
|
||||
|
||||
test('reports deleted resources when a token has no bindings left', async () => {
|
||||
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
|
||||
mockToken({
|
||||
...TOKEN_BASE,
|
||||
scope: 'project',
|
||||
// Deleting a project erases the token's binding to it.
|
||||
project_refs: [],
|
||||
permissions: ['database_read'],
|
||||
})
|
||||
renderSheet()
|
||||
|
||||
expect(await screen.findByText("This token's resources no longer exist")).toBeInTheDocument()
|
||||
expect(
|
||||
(await screen.findAllByText(/Every project this token was bound to has been deleted/)).length
|
||||
).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('This token no longer has access')).toBeNull()
|
||||
})
|
||||
})
|
||||
+336
-268
@@ -1,28 +1,26 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
ScrollArea,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import { ACCESS_TOKEN_RESOURCES } from '../AccessToken.constants'
|
||||
import { formatAccessText, getRealAccess } from '../AccessToken.utils'
|
||||
import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants'
|
||||
import {
|
||||
computeOverallRisk,
|
||||
PERMISSION_MODE_LABEL,
|
||||
scopesToSelection,
|
||||
type ResourceAccessMode,
|
||||
} from '../AccessToken.permissions'
|
||||
import { useCapabilitySummary } from '../hooks/useCapabilitySummary'
|
||||
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
|
||||
import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation'
|
||||
import { McpUnsupportedWarning } from './McpUnsupportedWarning'
|
||||
import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from './TokenSummaryRows'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { pluralize } from '@/lib/helpers'
|
||||
|
||||
interface ViewTokenSheetProps {
|
||||
visible: boolean
|
||||
@@ -30,8 +28,16 @@ interface ViewTokenSheetProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SCOPE_TO_RESOURCE_ACCESS: Record<'user' | 'organization' | 'project', ResourceAccessMode> = {
|
||||
user: 'account',
|
||||
organization: 'organization',
|
||||
project: 'project',
|
||||
}
|
||||
|
||||
const EMPTY_BINDINGS: string[] = []
|
||||
|
||||
export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProps) {
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
const { data: permissionScopeMap } = useGetEnabledEndpointsForCapability()
|
||||
|
||||
const {
|
||||
data: token,
|
||||
@@ -46,267 +52,329 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
|
||||
}
|
||||
)
|
||||
|
||||
const groupedResourcesByAccess = useMemo(() => {
|
||||
const grouped: Record<string, string[]> = {}
|
||||
// The sheet stays mounted (hidden) on the tokens page; don't fetch org/project data until it's
|
||||
// actually opened on a token.
|
||||
const { organizations, projects } = useOrgAndProjectData({ enabled: visible && !!token })
|
||||
|
||||
if (!token?.permissions) {
|
||||
return grouped
|
||||
}
|
||||
const resourceAccess = token ? SCOPE_TO_RESOURCE_ACCESS[token.scope] : 'project'
|
||||
const grantedScopes = useMemo(() => token?.permissions ?? [], [token?.permissions])
|
||||
|
||||
ACCESS_TOKEN_RESOURCES.forEach((resource) => {
|
||||
const access = getRealAccess(resource.resource, token.permissions)
|
||||
if (access !== 'no access') {
|
||||
const formattedAccess = formatAccessText(access)
|
||||
if (!grouped[formattedAccess]) {
|
||||
grouped[formattedAccess] = []
|
||||
}
|
||||
grouped[formattedAccess].push(resource.title)
|
||||
const selection = useMemo(() => scopesToSelection(grantedScopes), [grantedScopes])
|
||||
|
||||
const tokenOrganizationSlugs = token?.organization_slugs ?? EMPTY_BINDINGS
|
||||
const tokenProjectRefs = token?.project_refs ?? EMPTY_BINDINGS
|
||||
|
||||
const access = useTokenAccessEvaluation({
|
||||
selection,
|
||||
resourceAccess,
|
||||
organizationSlugs: tokenOrganizationSlugs,
|
||||
projectRefs: tokenProjectRefs,
|
||||
enabled: visible && !!token,
|
||||
})
|
||||
const hasExceedingCapabilities = access.exceedingEntryKeys.length > 0
|
||||
|
||||
// Deleting a project/org erases the token's binding to it, so a resource-scoped token with no
|
||||
// bindings left means everything it was bound to has been deleted.
|
||||
const hasNoBoundResources = token !== undefined && access.hasNoBoundResources
|
||||
|
||||
const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project'
|
||||
// Deleted bindings are erased from the token, so the original count is unknowable — the
|
||||
// phrasing has to work for any number of resources.
|
||||
const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted`
|
||||
|
||||
const risk = useMemo(
|
||||
() => computeOverallRisk(access.effectiveSelection, resourceAccess),
|
||||
[access.effectiveSelection, resourceAccess]
|
||||
)
|
||||
|
||||
const hasCapabilities = grantedScopes.length > 0
|
||||
|
||||
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
|
||||
selection,
|
||||
grantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
|
||||
// Accessible resources render with their name and ref/slug. Resources the user has lost access
|
||||
// to are aggregated into an anonymous count — their identifiers aren't shown.
|
||||
const resourceSummary = useMemo(() => {
|
||||
const inaccessibleCountItem = (lostCount: number, noun: string) =>
|
||||
lostCount === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'inaccessible',
|
||||
label: `${lostCount} ${pluralize(lostCount, noun)}`,
|
||||
sublabel: undefined,
|
||||
isInaccessible: true,
|
||||
},
|
||||
]
|
||||
|
||||
if (resourceAccess === 'project') {
|
||||
const projectsByRef = new Map(projects.map((project) => [project.ref, project]))
|
||||
const accessible = tokenProjectRefs.flatMap((ref) => {
|
||||
const name = projectsByRef.get(ref)?.name
|
||||
if (name === undefined) return []
|
||||
return [{ key: ref, label: name, sublabel: ref, isInaccessible: false }]
|
||||
})
|
||||
return {
|
||||
title: 'Projects',
|
||||
items: [
|
||||
...accessible,
|
||||
...inaccessibleCountItem(access.inaccessibleProjectRefs.length, 'project'),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}, [token?.permissions])
|
||||
|
||||
const getResourceAccessInfo = () => {
|
||||
const resources: Array<{ name: string; type: string; identifier: string }> = []
|
||||
|
||||
const organizationSlugs = token?.organization_slugs
|
||||
if (organizationSlugs && Array.isArray(organizationSlugs) && organizationSlugs.length > 0) {
|
||||
organizationSlugs.forEach((orgSlug: string) => {
|
||||
const org = organizations.find((o) => o.slug === orgSlug)
|
||||
resources.push({
|
||||
name: org?.name || orgSlug,
|
||||
type: 'Organization',
|
||||
identifier: orgSlug,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const projectRefs = token?.project_refs
|
||||
if (projectRefs && Array.isArray(projectRefs) && projectRefs.length > 0) {
|
||||
projectRefs.forEach((projectRef: string) => {
|
||||
const project = projects.find((p) => p.ref === projectRef)
|
||||
resources.push({
|
||||
name: project?.name || projectRef,
|
||||
type: 'Project',
|
||||
identifier: projectRef,
|
||||
})
|
||||
if (resourceAccess === 'organization') {
|
||||
const organizationsBySlug = new Map(organizations.map((org) => [org.slug, org]))
|
||||
const accessible = tokenOrganizationSlugs.flatMap((slug) => {
|
||||
const name = organizationsBySlug.get(slug)?.name
|
||||
if (name === undefined) return []
|
||||
return [{ key: slug, label: name, sublabel: slug, isInaccessible: false }]
|
||||
})
|
||||
return {
|
||||
title: 'Organizations',
|
||||
items: [
|
||||
...accessible,
|
||||
...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'),
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account-level access',
|
||||
sublabel: undefined,
|
||||
isInaccessible: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [
|
||||
resourceAccess,
|
||||
tokenProjectRefs,
|
||||
tokenOrganizationSlugs,
|
||||
projects,
|
||||
organizations,
|
||||
access.inaccessibleProjectRefs,
|
||||
access.inaccessibleOrgSlugs,
|
||||
])
|
||||
|
||||
return resources
|
||||
}
|
||||
const rows: [string, React.ReactNode][] = token
|
||||
? [
|
||||
[
|
||||
'Created',
|
||||
token.created_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.created_at}
|
||||
label={dayjs(token.created_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Unknown</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Last used',
|
||||
token.last_used_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.last_used_at}
|
||||
label={dayjs(token.last_used_at).fromNow()}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Never</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Expires',
|
||||
token.expires_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.expires_at}
|
||||
label={dayjs(token.expires_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Never</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Resource access',
|
||||
<div key="resource-access" className="space-y-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
{resourceSummary.title}
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{resourceSummary.items.length === 0 && hasNoBoundResources && (
|
||||
<p className="py-2 text-sm text-foreground-lighter">{boundResourcesDeletedText}</p>
|
||||
)}
|
||||
{resourceSummary.items.length === 0 && !hasNoBoundResources && (
|
||||
<p className="py-2 text-sm text-foreground-lighter">-</p>
|
||||
)}
|
||||
{resourceSummary.items.map((item) => (
|
||||
<ResourceSummaryItem
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
sublabel={item.sublabel}
|
||||
isInaccessible={item.isInaccessible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
'Capabilities',
|
||||
hasCapabilities ? (
|
||||
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
|
||||
) : (
|
||||
<span className="text-foreground-lighter">No capabilities selected</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Risk level',
|
||||
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
|
||||
],
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={() => onClose()}>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size="default"
|
||||
className="min-w-[600px]! flex flex-col h-full gap-0"
|
||||
>
|
||||
<SheetHeader
|
||||
className={cn('flex flex-row justify-between gap-x-4 items-center border-b')}
|
||||
>
|
||||
<p className="truncate" title={`Manage access for ${token?.name}`}>
|
||||
View access for {token?.name}
|
||||
</p>
|
||||
<DocsButton href={`${DOCS_URL}/reference/api/introduction`} />
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-60px)]">
|
||||
<div className="space-y-8 px-5 sm:px-6 py-6">
|
||||
{isTokenLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-foreground-light">Loading token information...</p>
|
||||
<Sheet open={visible} onOpenChange={() => onClose()}>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size="default"
|
||||
className="flex h-full flex-col gap-0 sm:w-[656px] lg:w-[800px]"
|
||||
>
|
||||
<SheetHeader className={cn('flex flex-row justify-between gap-x-4 items-center border-b')}>
|
||||
<p className="truncate" title={`View access for ${token?.name}`}>
|
||||
View access for {token?.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/guides/platform/access-control`}
|
||||
topic="Access control"
|
||||
label="Access control docs"
|
||||
/>
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/reference/api/introduction`}
|
||||
topic="Management API"
|
||||
label="API docs"
|
||||
/>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-6 px-5 sm:px-6 py-6">
|
||||
{isTokenLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-foreground-light">Loading token information...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenError && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-destructive">
|
||||
Error loading token information. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
{hasNoBoundResources && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="This token's resources no longer exist"
|
||||
description={`${boundResourcesDeletedText}. ${TOKEN_DENIED_REMEDIATION}`}
|
||||
/>
|
||||
)}
|
||||
{access.hasNoAccessibleResource && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="This token no longer has access"
|
||||
description={`You were removed from the ${resourceNoun}s this token is bound to. ${TOKEN_DENIED_REMEDIATION}`}
|
||||
/>
|
||||
)}
|
||||
{hasExceedingCapabilities && !access.hasNoAccessibleResource && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Some permissions exceed your current role for the selected resources"
|
||||
description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them."
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Token summary</h3>
|
||||
<dl className="divide-y rounded-md border bg-surface-300">
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className="grid grid-cols-3 gap-4 px-4 py-3">
|
||||
<dt className="text-sm text-foreground-lighter">{key}</dt>
|
||||
<dd className="col-span-2 text-sm text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenError && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-foreground-light text-red-500">
|
||||
Error loading token information. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasCapabilities && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Management API endpoints enabled</h3>
|
||||
{capabilityGroups.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No Management API endpoints are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
capabilityGroups.map(({ entry, mode, endpoints }) => (
|
||||
<div key={entry.key} className="rounded-md border">
|
||||
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
|
||||
<span className="text-xs text-foreground">{entry.name}</span>
|
||||
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
|
||||
{PERMISSION_MODE_LABEL[mode]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{endpoints.map(([method, path]) => (
|
||||
<div
|
||||
key={`${method} ${path}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span className="w-14 shrink-0 text-foreground-light">
|
||||
{method}
|
||||
</span>
|
||||
<span className="text-foreground">{path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{token && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Token Information</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Info
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Date
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Created</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.created_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.created_at}
|
||||
label={dayjs(token.created_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Unknown</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Last used</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.last_used_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.last_used_at}
|
||||
label={dayjs(token.last_used_at).fromNow()}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground-light">Expires</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token?.expires_at ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={token.expires_at}
|
||||
label={dayjs(token.expires_at).format('DD MMM YYYY')}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Resource Access</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Resource
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Type
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getResourceAccessInfo().length > 0 ? (
|
||||
getResourceAccessInfo().map((resource, index) => (
|
||||
<TableRow key={`${resource.type}-${resource.identifier}-${index}`}>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground">{resource.name}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-light">{resource.type}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<p className="text-foreground-light text-center py-4">
|
||||
{token?.scope === 'organization'
|
||||
? token.organization_slugs &&
|
||||
token.organization_slugs.length > 0
|
||||
? 'This token has access to specific organizations.'
|
||||
: 'This token has no accessible organizations.'
|
||||
: token?.scope === 'project'
|
||||
? token.project_refs && token.project_refs.length > 0
|
||||
? 'This token has access to specific projects.'
|
||||
: 'This token has no accessible projects.'
|
||||
: 'This token has access to all resources.'}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Permissions</h3>
|
||||
<Card className="w-full overflow-hidden bg-surface-100">
|
||||
<CardContent className="p-0">
|
||||
<Table className="p-5 table-auto">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-200">
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
|
||||
Permission
|
||||
</TableHead>
|
||||
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
|
||||
Access
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.keys(groupedResourcesByAccess).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<p className="text-foreground-light text-center py-4">
|
||||
No permissions configured for this token.
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
Object.entries(groupedResourcesByAccess).map(
|
||||
([accessLevel, resources]) => {
|
||||
return resources.map((resource) => (
|
||||
<TableRow key={`${accessLevel}-${resource}`}>
|
||||
<TableCell>
|
||||
<p className="truncate text-foreground capitalize">
|
||||
{resource}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-foreground-light">
|
||||
{formatAccessText(accessLevel)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
}
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">MCP tools</h3>
|
||||
<McpUnsupportedWarning />
|
||||
{mcpTools.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No MCP tools are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcpTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded border bg-surface-100 px-2 py-1 font-mono text-xs text-foreground-light"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import {
|
||||
getEntryScopes,
|
||||
PERMISSION_CATALOG_BY_CATEGORY,
|
||||
type PermissionCatalogEntry,
|
||||
type PermissionMode,
|
||||
type PermissionSelection,
|
||||
} from '../AccessToken.permissions'
|
||||
import {
|
||||
getEnabledEndpointsForCapability,
|
||||
getEnabledMcpTools,
|
||||
PermissionScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface UseCapabilitySummaryArgs {
|
||||
selection: PermissionSelection
|
||||
grantedScopes: string[]
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-derived summary data for the token view sheet: selected entries grouped by catalog
|
||||
* category, the Management API endpoints each capability enables, and the enabled MCP tools.
|
||||
*/
|
||||
export const useCapabilitySummary = ({
|
||||
selection,
|
||||
grantedScopes,
|
||||
permissionScopeMap,
|
||||
}: UseCapabilitySummaryArgs) => {
|
||||
const activeByCategory = useMemo(
|
||||
() =>
|
||||
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
|
||||
...category,
|
||||
entries: category.entries
|
||||
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
|
||||
.filter(({ mode }) => mode !== 'none'),
|
||||
})).filter((category) => category.entries.length > 0),
|
||||
[selection]
|
||||
)
|
||||
|
||||
const mcpTools = useMemo(
|
||||
() => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
|
||||
[grantedScopes, permissionScopeMap]
|
||||
)
|
||||
|
||||
const capabilityGroups = useMemo(() => {
|
||||
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
|
||||
[]
|
||||
for (const category of activeByCategory) {
|
||||
for (const { entry, mode } of category.entries) {
|
||||
const capabilityScopes = getEntryScopes(entry, mode)
|
||||
const endpoints = getEnabledEndpointsForCapability({
|
||||
capabilityScopes,
|
||||
allGrantedScopes: grantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
if (endpoints.length > 0) {
|
||||
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}, [activeByCategory, grantedScopes, permissionScopeMap])
|
||||
|
||||
return { activeByCategory, mcpTools, capabilityGroups }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { BaseToken } from '../AccessToken.types'
|
||||
import { useAccessTokensQuery } from '@/data/access-tokens/access-tokens-query'
|
||||
import { useScopedAccessTokensQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
|
||||
|
||||
export type TokenKind = 'classic' | 'scoped'
|
||||
|
||||
export type MergedAccessToken = ClassicAccessToken | ScopedAccessToken
|
||||
|
||||
export interface ClassicAccessToken extends BaseToken {
|
||||
id: number
|
||||
kind: 'classic'
|
||||
}
|
||||
|
||||
export interface ScopedAccessToken extends BaseToken {
|
||||
id: string
|
||||
kind: 'scoped'
|
||||
}
|
||||
|
||||
interface UseMergedAccessTokensOptions {
|
||||
/** Whether scoped tokens should be fetched + merged (gated on the scopedPAT flag). */
|
||||
scopedTokensEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges classic and scoped access tokens into a single list, tagging each row with its `kind`.
|
||||
* Classic is the baseline (its loading/error state drives the list); scoped failures degrade
|
||||
* gracefully so a missing/undeployed scoped endpoint never hides classic tokens. Sorting is left to
|
||||
* the consuming list (via filterAndSortTokens), so this only merges + tags.
|
||||
*/
|
||||
export const useMergedAccessTokens = ({
|
||||
scopedTokensEnabled,
|
||||
}: UseMergedAccessTokensOptions = {}) => {
|
||||
const classic = useAccessTokensQuery()
|
||||
const scoped = useScopedAccessTokensQuery({ enabled: scopedTokensEnabled })
|
||||
|
||||
return useMemo(() => {
|
||||
const classicTokens: MergedAccessToken[] = (classic.data ?? []).map((token) => ({
|
||||
...token,
|
||||
kind: 'classic',
|
||||
}))
|
||||
const scopedTokens: MergedAccessToken[] = (scoped.data?.tokens ?? []).map((token) => ({
|
||||
...token,
|
||||
kind: 'scoped',
|
||||
}))
|
||||
return {
|
||||
tokens: [...classicTokens, ...scopedTokens],
|
||||
// Classic drives the primary states; a scoped fetch that is still loading shouldn't block the list.
|
||||
isLoading: classic.isPending || (scopedTokensEnabled && scoped.isPending),
|
||||
isError: classic.isError,
|
||||
error: classic.error,
|
||||
isSuccess: classic.isSuccess,
|
||||
}
|
||||
}, [
|
||||
classic.data,
|
||||
classic.isPending,
|
||||
classic.isError,
|
||||
classic.error,
|
||||
classic.isSuccess,
|
||||
scoped.data,
|
||||
scoped.isPending,
|
||||
scopedTokensEnabled,
|
||||
])
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { PermissionSelection, ResourceAccessMode } from '../AccessToken.permissions'
|
||||
import {
|
||||
applySelectionToRoleContext,
|
||||
computeTokenRoleContext,
|
||||
type TokenAccessEvaluation,
|
||||
} from '../AccessToken.roles'
|
||||
import { useOrgAndProjectData } from './useOrgAndProjectData'
|
||||
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
|
||||
|
||||
interface UseTokenAccessEvaluationArgs {
|
||||
selection: PermissionSelection
|
||||
resourceAccess: ResourceAccessMode
|
||||
organizationSlugs: string[]
|
||||
projectRefs: string[]
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a token's scope selection and bound resources against the current user's live access.
|
||||
* Advisory only — actual enforcement is the per-request intersection on the API side. While the
|
||||
* underlying queries load (or on self-hosted), the evaluation reports `status: 'unknown'` and
|
||||
* callers must show no warnings rather than flash false ones.
|
||||
*
|
||||
* Role resolution (the expensive part) is memoized separately from the selection, so toggling
|
||||
* permissions in the form only re-runs the cheap selection pass.
|
||||
*/
|
||||
export const useTokenAccessEvaluation = ({
|
||||
selection,
|
||||
resourceAccess,
|
||||
organizationSlugs,
|
||||
projectRefs,
|
||||
enabled = true,
|
||||
}: UseTokenAccessEvaluationArgs): TokenAccessEvaluation => {
|
||||
const { data: permissions } = usePermissionsQuery({ enabled })
|
||||
const { organizations, projects, isLoadingOrgs, isLoadingProjects } = useOrgAndProjectData({
|
||||
enabled,
|
||||
})
|
||||
|
||||
// Org/project lists still loading: resources the user *does* have access to would read as
|
||||
// inaccessible, so report unknown instead.
|
||||
const hasCompleteResourceLists = !isLoadingOrgs && !isLoadingProjects
|
||||
|
||||
const context = useMemo(
|
||||
() =>
|
||||
computeTokenRoleContext({
|
||||
resourceAccess,
|
||||
organizationSlugs,
|
||||
projectRefs,
|
||||
permissions: hasCompleteResourceLists ? permissions : undefined,
|
||||
organizations,
|
||||
projects,
|
||||
}),
|
||||
[
|
||||
resourceAccess,
|
||||
organizationSlugs,
|
||||
projectRefs,
|
||||
permissions,
|
||||
organizations,
|
||||
projects,
|
||||
hasCompleteResourceLists,
|
||||
]
|
||||
)
|
||||
|
||||
return useMemo(() => applySelectionToRoleContext(context, selection), [context, selection])
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function PolicySearchResults({ query }: PolicySearchResultsProps) {
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pb-9">
|
||||
<ResultsList
|
||||
results={policyResults}
|
||||
icon={Auth}
|
||||
@@ -150,7 +150,6 @@ export function PolicySearchResults({ query }: PolicySearchResultsProps) {
|
||||
}
|
||||
return `/project/${projectRef}/database/policies?${params.toString()}` as `/${string}`
|
||||
}}
|
||||
className="pb-9"
|
||||
/>
|
||||
</div>
|
||||
{renderFooter()}
|
||||
|
||||
@@ -49,6 +49,14 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) =
|
||||
return { ...a, [b.key]: false }
|
||||
}
|
||||
|
||||
// A forced preview has become the default behavior, so it's on whatever
|
||||
// the user stored previously — including an explicit opt-out. Applied
|
||||
// here rather than in the individual `useIsXEnabled` helpers so that the
|
||||
// feature preview modal reflects it too.
|
||||
if (b.isForced) {
|
||||
return { ...a, [b.key]: true }
|
||||
}
|
||||
|
||||
const defaultOptIn = b.isDefaultOptIn
|
||||
const localStorageValue = safeLocalStorage.getItem(b.key)
|
||||
return {
|
||||
@@ -127,10 +135,14 @@ export const useIsJitDbAccessEnabled = () => {
|
||||
return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SQL Editor saves snippets only on request. True when the user
|
||||
* opted into the preview themselves *or* the `sqlEditorManualSaveForced` rollout
|
||||
* has reached them — the preview's `isForced` flag folds the latter into `flags`.
|
||||
*/
|
||||
export const useIsSqlEditorManualSaveEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
|
||||
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
|
||||
}
|
||||
|
||||
export const useIsMarketplaceEnabled = () => {
|
||||
|
||||
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user