diff --git a/.claude/skills/clickhouse-logs-queries/SKILL.md b/.claude/skills/clickhouse-logs-queries/SKILL.md new file mode 100644 index 00000000000..91960c73179 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/SKILL.md @@ -0,0 +1,226 @@ +--- +name: clickhouse-logs-queries +description: >- + Write, review, and migrate Supabase logs queries against the ClickHouse-backed + `logs` table (the `logs.all.otel` analytics endpoint). Use this whenever a task + involves Logs Explorer SQL, the `log_attributes` map, querying a log `source` + (edge_logs, postgres_logs, auth_logs, etc.), translating an old BigQuery + `cross join unnest(metadata)` logs query to ClickHouse, or wiring analytics log + SQL in `apps/studio/data/logs` and `apps/studio/components/interfaces/Settings/Logs`. + Reach for it even when the user just says "logs query", "Logs Explorer", or + pastes a BigQuery logs query to convert, not only when they name ClickHouse. +--- + +# Querying Supabase logs (ClickHouse) + +Supabase logs live in a single ClickHouse `logs` table, served by the +`logs.all.otel` analytics endpoint. Every log line from every part of the stack +is one row in this table, tagged by a `source` column. This replaces the older +BigQuery model, where each service had its own table and fields were reached +through `cross join unnest(metadata)`. + +Two kinds of work use this skill, and they share the same SQL model: + +1. **Writing or reviewing a logs query** (in the Logs Explorer or anywhere a raw + ClickHouse logs query is needed). Start here in this file. +2. **Wiring a logs query in the Studio codebase** (branded analytics SQL, the + endpoint picker, the OTEL query builders). Read + [references/codebase-integration.md](references/codebase-integration.md). + +If you are converting an existing BigQuery logs query, read +[references/bigquery-migration.md](references/bigquery-migration.md) for the full +translation table. + +## The logs table + +Each row has a small set of real columns. Everything specific to a service lives +in `log_attributes`. + +| Column | Type | Notes | +| ---------------- | --------------------- | ----------------------------------------------------- | +| `id` | `String` | Unique log identifier. | +| `timestamp` | `DateTime64` (UTC) | When the log was produced. Order/compare it directly. | +| `event_message` | `String` | The raw log line. | +| `severity_text` | `String` | Log level, when the source sets one. | +| `source` | `String` | The service the log came from. Always filter on this. | +| `log_attributes` | `Map(String, String)` | Structured per-source fields, keyed by a dotted path. | + +`timestamp` is formatted like `2026-06-22T09:34:06.215000` (ISO 8601, microsecond +precision, no trailing `Z`). In the Logs Explorer the selected time range is +applied for you, so you rarely need to write a `timestamp` filter by hand. + +A minimal, well-formed query. Lead with a comment naming the query, filter by +`source`, and always `limit`: + +```sql +-- recent edge requests +select timestamp, event_message +from logs +where source = 'edge_logs' +order by timestamp desc +limit 100; +``` + +## Sources + +`source` selects the service. The common ones: + +- `edge_logs` — API gateway requests and responses +- `postgres_logs` — database statements and errors (also where pg_cron logs live) +- `auth_logs` — authentication and authorization activity +- `function_edge_logs` — edge function requests and responses +- `function_logs` — `console` output from inside edge functions +- `storage_logs` — object upload and retrieval activity +- `realtime_logs` — Realtime client connections +- `postgrest_logs`, `supavisor_logs`, `pgbouncer_logs` — mostly `id`, `timestamp`, `event_message` + +The Logs Explorer **Field Reference** drawer lists every source and the fields it +actually sets. When in doubt about a key, discover it from real data rather than +guessing (see below). + +## Reading fields from log_attributes + +`log_attributes` maps a string key to a string value. Read a field with bracket +access. There are no unnesting joins: + +```sql +select + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['response.status_code'] as status +from logs +where source = 'edge_logs' +``` + +The key keeps the dotted path that BigQuery expressed through nested structs, with +the `metadata` root dropped: BigQuery `metadata.request.method` becomes +`log_attributes['request.method']`. Keep the full prefix — `request.cf.country` +is `log_attributes['request.cf.country']`, not `log_attributes['cf.country']`. + +Common keys by source: + +- `edge_logs`: `request.method`, `request.path`, `request.search`, `response.status_code`, `identifier` +- `postgres_logs`: `parsed.error_severity`, `parsed.detail`, `parsed.hint`, `parsed.query`, `identifier` +- `auth_logs`: `level`, `status`, `path`, `msg`, `error` +- `function_edge_logs`: `response.status_code`, `request.method`, `request.pathname`, `function_id`, `execution_id`, `execution_time_ms` +- `function_logs`: `event_type`, `function_id`, `execution_id`, `level` + +### Numeric fields are strings + +Map values are always strings. To compare or aggregate a numeric field, wrap it in +`toInt32OrZero`, which returns `0` for missing or non-numeric values so it never +errors on partial data: + +```sql +select count() as server_errors +from logs +where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599 +``` + +### Discover the keys a source sets + +Read `mapKeys` from recent rows rather than guessing key names: + +```sql +select arrayJoin(mapKeys(log_attributes)) as key, count() as n +from logs +where source = 'postgres_logs' +group by key +order by n desc +limit 100; +``` + +`arrayJoin(mapKeys(...))` flattens the map keys into one row per key so you can +rank them by frequency. (The Studio codebase does exactly this for the Field +Reference drawer and to feed real keys to the AI rewrite.) + +## ClickHouse vs BigQuery functions + +These are the substitutions that trip people up most: + +| Need | BigQuery | ClickHouse | +| ------------------ | ----------------------------- | -------------------------------------------- | +| Count rows | `count(*)` | `count()` | +| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` | +| Substring match | `x like '%p%'` | `x ilike '%p%'` (case-insensitive) or `like` | +| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` | +| Read the timestamp | `cast(timestamp as datetime)` | `timestamp` (use the column directly) | +| Map keys | n/a (used unnest) | `mapKeys(log_attributes)` | + +The `logs.all.otel` analytics endpoint (and the Logs Explorer on top of it) +rejects `count(*)` and `select *` — use `count()` and list the columns you need. +(Raw ClickHouse supports both; this is a constraint of the logs query surface.) + +## Best practices + +These keep queries correct and cheap. Log tables are large; an unbounded scan +reads far more data than you need. + +- **Start every query with an identifying comment** (e.g. `-- errors since last deploy`). It labels the query in logs and review, and makes each of several queries in a file easy to tell apart. +- **Always include a `LIMIT`.** Even for aggregates while you iterate. +- **Always query `from logs where source = '...'`.** There is no per-service table (no `edge_logs`, `postgres_logs`, etc. table) — there is one `logs` table, and `source` scopes it to a service. Filtering by `source` is required, not just an optimization. +- **Keep the time range tight.** A smaller window returns results faster. +- **Filter on the real columns** (`source`, `timestamp`) before reaching into + `log_attributes`. +- **Order by `timestamp desc`** to see the most recent logs first. +- **Use `count()`**, not `count(*)` or `select *`. + +## Worked examples + +Requests by status code: + +```sql +select + toInt32OrZero(log_attributes['response.status_code']) as status, + count() as count +from logs +where source = 'edge_logs' +group by status +order by count desc +limit 50 +``` + +Auth errors: + +```sql +select timestamp, event_message, log_attributes['msg'] as message +from logs +where source = 'auth_logs' + and log_attributes['level'] in ('error', 'fatal') +order by timestamp desc +limit 100 +``` + +Search the raw message: + +```sql +select timestamp, event_message +from logs +where source = 'postgres_logs' + and event_message ilike '%deadlock%' +order by timestamp desc +limit 100 +``` + +Postgres errors grouped by severity (the canonical unnest-to-map conversion): + +```sql +select log_attributes['parsed.error_severity'] as severity, count() as count +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by severity +order by count desc +limit 100 +``` + +## When the user pastes a BigQuery query + +Convert it rather than running it as-is. The mechanical steps (drop the +per-service table for `from logs where source = ...`, remove every +`cross join unnest(...)`, rewrite unnest-alias columns as `log_attributes['...']` +lookups, swap the functions above) are spelled out with a full before/after in +[references/bigquery-migration.md](references/bigquery-migration.md). The Logs +Explorer also has a built-in **Rewrite to ClickHouse** action that does this with +AI; point users to it for one-off conversions in the dashboard. diff --git a/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md b/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md new file mode 100644 index 00000000000..09d84f813b0 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/references/bigquery-migration.md @@ -0,0 +1,93 @@ +# Migrating a BigQuery logs query to ClickHouse + +The old logs engine gave each service its own table and exposed structured fields +through repeated `cross join unnest(...)` over a nested `metadata` column. The +ClickHouse engine has one `logs` table and a flat `log_attributes` map. Conversion +is mechanical once you internalize the mapping. + +## The five steps + +1. **Replace the table with `logs` plus a `source` filter.** The old table name is + the `source` value: `from postgres_logs as t` becomes + `from logs where source = 'postgres_logs'`. Never select from a per-service + table name (`postgres_logs`, `edge_logs`, ...) on the ClickHouse engine. + - Exception: pg_cron logs live under `source = 'postgres_logs'`. + +2. **Remove every unnest join.** Delete `cross join unnest(metadata) as m`, + `cross join unnest(m.parsed) as p`, `left join unnest(...) on true`, and so on. + They have no equivalent — the data is already flat in the map. + +3. **Rewrite every unnest-alias column as a map lookup.** A field taken off + `unnest(metadata)` becomes `log_attributes['field']`. A field off a nested + struct like `unnest(m.parsed)` becomes `log_attributes['parsed.field']`: keep + the struct name as a dotted prefix, drop the `metadata` root and every alias. + +4. **Wrap numeric fields in `toInt32OrZero(...)`** before comparing or aggregating + them — map values are strings. + +5. **Swap BigQuery functions for ClickHouse ones** (see the table below). + +Preserve the original select list, filters, group by, order by, and limit intent +throughout. + +## Function substitutions + +| Need | BigQuery | ClickHouse | +| ---------------- | ----------------------------- | --------------------------- | +| Count rows | `count(*)` | `count()` | +| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` | +| Substring match | `x like '%p%'` | `x ilike '%p%'` or `like` | +| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` | +| Timestamp value | `cast(timestamp as datetime)` | `timestamp` | +| All columns | `select *` | list the columns explicitly | + +## Full before/after + +BigQuery: + +```sql +select count(t.timestamp) as count, p.error_severity +from + postgres_logs as t + cross join unnest(metadata) as m + cross join unnest(m.parsed) as p +where p.error_severity in ('ERROR', 'FATAL', 'PANIC') +group by p.error_severity +order by count desc +limit 100; +``` + +ClickHouse: + +```sql +select count() as count, log_attributes['parsed.error_severity'] as error_severity +from logs +where source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') +group by log_attributes['parsed.error_severity'] +order by count desc +limit 100 +``` + +Notice: `count(t.timestamp)` became `count()`, the two unnest joins are gone, +`p.error_severity` became `log_attributes['parsed.error_severity']`, and the +`from`/`where` targets the single table. + +## Getting the keys right + +The most common conversion mistake is dropping a dotted prefix — writing +`log_attributes['x_real_ip']` when the real key is +`log_attributes['request.headers.x_real_ip']`, or `log_attributes['cf.country']` +instead of `log_attributes['request.cf.country']`. Do not invent or shorten keys. +When unsure, discover the real keys from data: + +```sql +select arrayJoin(mapKeys(log_attributes)) as key, count() as n +from logs +where source = 'edge_logs' +group by key +order by n desc +limit 200; +``` + +Then map each old nested path to the matching key exactly as it appears. diff --git a/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md b/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md new file mode 100644 index 00000000000..05c55fa9767 --- /dev/null +++ b/.claude/skills/clickhouse-logs-queries/references/codebase-integration.md @@ -0,0 +1,96 @@ +# Wiring a logs query in the Studio codebase + +This covers writing analytics log SQL inside `apps/studio` so it is safe, routed +to the right endpoint, and consistent with the existing OTEL builders. Read this +when you are editing TypeScript that builds or runs a logs query, not when you are +just writing a query in the Logs Explorer UI. + +## Branded SQL: never concatenate user input + +All analytics log SQL must be a `SafeLogSqlFragment`, built with the helpers in +`apps/studio/data/logs/safe-analytics-sql.ts`. This is enforced by eslint, and the +branding is what keeps interpolated values from becoming injection. The key +exports: + +- `safeSql\`...\``— a tagged template that only accepts`SafeLogSqlFragment`interpolations. Plain strings (and Postgres-branded`SafeSqlFragment`) are + rejected at compile time, so you cannot accidentally drop a raw value in. +- `analyticsLiteral(value)` — turns a string/number/boolean into a safely escaped + literal fragment (single quotes and backslashes are escaped). Use it for every + dynamic value, especially the `source`. +- `joinSqlFragments(fragments, separator)` — joins already-safe fragments with a + fixed structural separator (`' and '`, `', '`, etc.). +- `keyword(value, allowed)` — resolves a value against a compile-time allow-list of + fragments (e.g. an `AND`/`OR` operator). Returns the allow-listed fragment, never + the raw input. +- `quotedIdent(value)` — backtick-quotes a dotted identifier path after validating + each segment. + +There is intentionally no exported "raw" escape hatch. Compose with `safeSql` plus +these helpers. + +```ts +import { analyticsLiteral, safeSql } from 'data/logs/safe-analytics-sql' + +const source = 'edge_logs' +const sql = safeSql` + select timestamp, event_message + from logs + where source = ${analyticsLiteral(source)} + order by timestamp desc + limit 100 +` +``` + +## Pick the endpoint and builder by flag + +The ClickHouse path is gated by the `otelLegacyLogs` PostHog flag +(`useFlag('otelLegacyLogs')` from `common`). Keep the BigQuery path working when +the flag is off. Two helpers in `apps/studio/data/logs/logs-endpoint.ts` express +the split: + +- `logsAllEndpointUrl(useOtel)` — returns the `logs.all.otel` endpoint when + `useOtel`, otherwise the legacy `logs.all` endpoint. +- `pickLogsQueryBuilder(useOtel, otel, bq)` — picks between an OTEL builder and a + BigQuery builder while preserving the type. + +```ts +const useOtel = useFlag('otelLegacyLogs') +const builder = pickLogsQueryBuilder(useOtel, genDefaultQueryOtel, genDefaultQuery) +const endpoint = logsAllEndpointUrl(useOtel) +// include { otel: useOtel } in the React Query key so the two paths cache separately +``` + +Run the fragment through `executeAnalyticsSql` (`apps/studio/data/logs/execute-analytics-sql.ts`) +against that endpoint. + +## Follow the existing OTEL builders + +When you need a new query shape, mirror the generators in +`apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts` rather than +inventing a parallel style. They already encode the conventions: + +- `genDefaultQueryOtel`, `genCountQueryOtel`, `genChartQueryOtel`, + `genSingleLogQueryOtel` — the row/count/chart/single-log builders. They select + the real columns plus source-specific `log_attributes[...]` lookups aliased to + the leaf names the renderers expect. +- `mapOtelPreviewRow`, `mapOtelSingleLogToLegacy`, `otelTimestampToMicros` — the + JS normalization layer. `timestamp` must end up a microsecond number for the + pagination cursor and the renderers, so reuse `otelTimestampToMicros` rather + than parsing the ISO string yourself. + +The map-key discovery hook (`apps/studio/data/logs/otel-log-keys-query.ts`, +`fetchOtelLogKeys` / `useOtelLogKeysQuery`) is the canonical example of a small, +correctly-branded OTEL query — read it before writing a new one. + +## Checklist + +- [ ] Every dynamic value goes through `analyticsLiteral` (or another sanitizer), + never string concatenation. +- [ ] The query filters by `source` and includes a `LIMIT`. +- [ ] Numeric `log_attributes` values are wrapped in `toInt32OrZero`. +- [ ] The endpoint and builder are chosen with `logsAllEndpointUrl` / + `pickLogsQueryBuilder` off `useFlag('otelLegacyLogs')`. +- [ ] The React Query key distinguishes the OTEL and BigQuery paths. +- [ ] `timestamp` is normalized to micros for any row consumed by the table/cursor. +- [ ] There is a unit test asserting the generated SQL string (see + `Logs.utils.otel.test.ts` and `safe-analytics-sql.test.ts` for the pattern). diff --git a/.claude/skills/studio-e2e-tests/SKILL.md b/.claude/skills/studio-e2e-tests/SKILL.md index 1006981e42d..ecd48bc8376 100644 --- a/.claude/skills/studio-e2e-tests/SKILL.md +++ b/.claude/skills/studio-e2e-tests/SKILL.md @@ -88,7 +88,7 @@ test.describe.configure({ mode: 'serial' }) 3. **`getByText` with exact match** - Good for unique text ```typescript - page.getByText('Data API Access', { exact: true }) + page.getByText('Data API access', { exact: true }) ``` 4. **`locator` with CSS** - Use sparingly, more fragile diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md index 2a67de2bee4..c929e767a13 100644 --- a/.claude/skills/studio-ui-patterns/SKILL.md +++ b/.claude/skills/studio-ui-patterns/SKILL.md @@ -125,3 +125,9 @@ Forms in sheets: - `layout="horizontal"` for wider sheets - `layout="vertical"` for narrow sheets (`size="sm"` or below) + +## Copy + +Source of truth: `apps/design-system/content/docs/copywriting.mdx` — sentence case, title case, proper nouns, voice and tone. + +When changing visible copy, grep `e2e/studio/` for the old string. diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000000..502aa46c4b9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,92 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +# Don't inherit organization-level settings (they're tuned for other repos); +# this config is self-contained and unset values use CodeRabbit defaults. +inheritance: false + +# Enrich linked issues with related code and potential solutions during review. +issue_enrichment: + auto_enrich: + enabled: true + +reviews: + # Skip machine-generated / vendored files (mirrors .prettierignore). Keeps + # reviews focused on hand-written code and preserves rate-limit budget on + # large codegen diffs. + path_filters: + - '!pnpm-lock.yaml' + - '!packages/api-types/types/**' # generated API types (api.d.ts, platform.d.ts) + - '!supabase/functions/common/database-types.ts' # generated by `pnpm generate:types` + - '!**/routeTree.gen.ts' # TanStack Router generated + - '!**/__generated__/**' + - '!apps/docs/features/docs/generated/**' + - '!apps/www/.generated/**' + - '!apps/design-system/__registry__/**' + - '!apps/ui-library/__registry__/**' + - '!apps/ui-library/public/r/**' # registry output + - '!packages/icons/__registry__/**' + - '!packages/icons/src/icons/**' # generated icon components + + # Targeted, path-scoped review guidance, version-controlled alongside the code. + path_instructions: + - path: 'packages/common/telemetry-constants.ts' + instructions: | + Strictly enforce event naming: [object]_[verb] in snake_case. Only approved + verbs: opened, clicked, submitted, created, removed, updated, retrieved, + intended, evaluated, added, enabled, disabled, copied, exposed, failed, + converted. Properties must be camelCase for new events (match existing + convention when adding to existing events). Flag any usage of + useSendEventMutation. Verify @group Events and @source JSDoc tags are + accurate. Check that new interfaces are added to the TelemetryEvent union type. + - path: 'apps/studio/components/**/!(*.test).tsx' # production components only, not tests + instructions: | + Only suggest adding PostHog event tracking (via useTrack from + lib/telemetry/track, [object]_[verb] snake_case) when a new user-facing + interaction is growth-relevant: e.g. first-use of a feature, onboarding steps, + project/org creation, upgrade/billing actions, enabling or disabling a product + feature, or any action that signals activation or retention. Do not suggest + tracking for: passive views, page loads, UI-only state changes (e.g. expanding + a panel, switching tabs in a settings page), developer/internal tooling + interactions, or interactions clearly unrelated to product adoption. + - path: 'apps/studio/pages/**' + instructions: | + Studio is mid-migration from the Next.js pages router (apps/studio/pages/**) + to TanStack Start (apps/studio/routes/**). Both runtimes ship side-by-side, so + every URL served from pages/** has a mirror in routes/**. See + apps/studio/TANSTACK_MIGRATION.md for the full route map and strategy. + Leave a comment reminding the author to check whether this change needs to be + mirrored into the corresponding apps/studio/routes/** file so the two builds + don't silently drift: + - Most route files re-export the page's default export (Path A), so pure + page-body edits propagate automatically — no mirror needed. + - A mirror IS needed when the change touches something the route file + duplicates rather than imports: getLayout / layout wrapping, page title or + other props the route encodes as staticData, withAuth / auth gating, or the + route/redirect path itself. + - A brand-new page under pages/** needs a matching new route under routes/** + (and a checklist entry in apps/studio/TANSTACK_MIGRATION.md). + - Do NOT suggest deleting the pages/** file — the Next file stays load-bearing + for both runtimes until the final cleanup pass (tracked in FE-3106). + Keep this a reminder to verify, not a hard blocker: if no mirror is required, + say so briefly rather than forcing a change. + +# Applies our internal engineering skills (.claude/skills/) as CodeRabbit review +# guidelines. The skills are the single source of truth — they are consumed +# directly, with no copy of their content elsewhere. +# +# `applyTo` decouples where a guideline file lives from the code it governs. +# Without it, CodeRabbit scopes a guideline file to its own directory and below; +# our skills live in .claude/skills/, which contains no code, so they would never +# reach apps/studio. `applyTo` points them at the right paths instead. +knowledge_base: + code_guidelines: + filePatterns: + # Studio code conventions — React/TS, UI patterns, composition, data fetching, errors + - files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling}/SKILL.md' + applyTo: 'apps/studio/**/*.{ts,tsx}' + # Studio unit / component test conventions + - files: '.claude/skills/{studio-testing,studio-mock-api-tests}/SKILL.md' + applyTo: 'apps/studio/**/*.test.{ts,tsx}' + # Studio end-to-end (Playwright) test conventions + - files: '.claude/skills/studio-e2e-tests/SKILL.md' + applyTo: 'e2e/studio/**/*.spec.ts' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 201d1b6dc87..3450bcab20b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /apps/studio/ @supabase/Dashboard +/apps/docs/ @supabase/docs + /apps/www/ @supabase/marketing /apps/www/public/images/blog @supabase/marketing /apps/www/lib/redirects.js @@ -19,4 +21,4 @@ /apps/studio/components/interfaces/Organization/Documents/ @supabase/security /apps/studio/pages/new/index.tsx @supabase/security -/packages/shared-data/compute-disk-limits.ts @supabase/infra +/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 827b4111518..b002e4a7b99 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -54,6 +54,7 @@ Path-specific rules in `.github/instructions/`: - **Error Handling**: `studio-error-handling.instructions.md` — error classification, `ErrorMatcher` usage - **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`) - **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components +- **UI Copy**: `studio-copy.instructions.md` → `apps/design-system/content/docs/copywriting.mdx` - **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag - **Keyboard Shortcuts**: `studio-shortcuts.instructions.md` — shortcut registry pattern, search-input escape handler, when to flag missing coverage diff --git a/.github/instructions/studio-copy.instructions.md b/.github/instructions/studio-copy.instructions.md new file mode 100644 index 00000000000..7b662e0cf4f --- /dev/null +++ b/.github/instructions/studio-copy.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: 'apps/studio/**' +--- + +# Studio UI Copy + +All comments are **advisory**. + +**Source of truth:** `apps/design-system/content/docs/copywriting.mdx` — read it before writing or reviewing user-facing Studio strings. + +## Agent checklist (not in the design doc) + +- When changing visible copy, grep `e2e/studio/` and `.github/instructions/` for the old string. diff --git a/.github/instructions/studio-e2e-tests.instructions.md b/.github/instructions/studio-e2e-tests.instructions.md index 58488df8d18..590512b176b 100644 --- a/.github/instructions/studio-e2e-tests.instructions.md +++ b/.github/instructions/studio-e2e-tests.instructions.md @@ -23,7 +23,7 @@ All comments are **advisory**. 3. **`getByText` with exact match** — good for unique text ```typescript - page.getByText('Data API Access', { exact: true }) + page.getByText('Data API access', { exact: true }) ``` 4. **`locator` with CSS** — use sparingly, more fragile diff --git a/.github/workflows/braintrust-evals.yml b/.github/workflows/braintrust-evals.yml index a2f9075f913..d05093acd61 100644 --- a/.github/workflows/braintrust-evals.yml +++ b/.github/workflows/braintrust-evals.yml @@ -15,8 +15,42 @@ permissions: contents: read jobs: + # Cheap, always-on gate: verifies the eval harness can reach the MCP server + # (its one real tool, search_docs). Runs on every push/PR — no OpenAI, no full + # eval suite — so a broken MCP connection is caught early with an actionable + # message. The eval job depends on this, so evals never run against a broken + # MCP connection. + preflight: + name: Eval MCP preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Preflight — MCP connectivity + run: cd apps/studio && pnpm evals:preflight + eval: name: Run evals + needs: preflight if: github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-evals') && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 20 @@ -43,8 +77,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version-file: ".nvmrc" - cache: "pnpm" + node-version-file: '.nvmrc' + cache: 'pnpm' - name: Install Dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index 4dbf1f24b44..bbaddc5fb27 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -55,6 +55,13 @@ jobs: working-directory: apps/docs/spec run: make download.tsdoc.v2 + - name: Generate Dart reference dump + # Dart has no upstream TypeDoc dump; its gitignored dump is generated + # from the committed `supabase_dart_v2.yml` so the reference-content + # snapshot test has something to walk. + working-directory: apps/docs + run: pnpm run codegen:references:dart + - name: Run tests run: | touch .env diff --git a/.github/workflows/studio-e2e-test.yml b/.github/workflows/studio-e2e-test.yml index 73204845f78..62505564f76 100644 --- a/.github/workflows/studio-e2e-test.yml +++ b/.github/workflows/studio-e2e-test.yml @@ -80,10 +80,6 @@ jobs: restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}- - - name: Reset supabase - if: steps.filter.outputs.studio == 'true' - run: rm -rf supabase && pnpm exec supabase init && mkdir supabase/functions - # Authenticate with AWS ECR to avoid rate limiting - name: configure aws credentials if: steps.filter.outputs.studio == 'true' && !github.event.pull_request.head.repo.fork diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml index 4b75674a3d7..c1a20d90fa9 100644 --- a/.github/workflows/ui-patterns-tests.yml +++ b/.github/workflows/ui-patterns-tests.yml @@ -40,5 +40,13 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + - name: Validate exports + run: | + pnpm --filter ui-patterns gen:exports + if ! git diff --ignore-space-at-eol --exit-code --quiet packages/ui-patterns/package.json; then + echo "Detected uncommitted changes after build. Run pnpm --filter ui-patterns gen:exports to fix it."; + git diff; + exit 1; + fi - name: Run tests run: pnpm run test:ui-patterns diff --git a/.gitignore b/.gitignore index 5691dd146de..ce23cae5706 100644 --- a/.gitignore +++ b/.gitignore @@ -112,7 +112,8 @@ next-env.d.ts # DynamoDB Local files .dynamodb/ -.vscode +.vscode/* +!.vscode/content-listing.code-snippets .idea .vercel diff --git a/.prettierignore b/.prettierignore index 66e4d58fa92..1f07211dd8f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,6 +21,8 @@ apps/studio/public apps/**/.turbo apps/docs/CONTRIBUTING.md apps/docs/__generated__ +# Generated by apps/docs/internals/generate-markdown-manifest.ts +apps/docs/lib/markdown-manifest.ts apps/design-system/__registry__ # TanStack Router auto-generated route tree; the file header explicitly # says to exclude it from formatters. diff --git a/.vscode/content-listing.code-snippets b/.vscode/content-listing.code-snippets new file mode 100644 index 00000000000..580eda912cd --- /dev/null +++ b/.vscode/content-listing.code-snippets @@ -0,0 +1,28 @@ +{ + "Content listing data export": { + "prefix": "cl-data", + "scope": "typescript", + "description": "ContentListingGroup export for overview listing blocks", + "body": [ + "export const ${1:topic}${2:Section}: ContentListingGroup = {", + " id: '${3:topic-section}',", + " heading: '${4:Section heading}',", + " description: '${5:Optional intro sentence}',", + " type: '${6|grid,list|}',", + " items: [", + " {", + " title: '${7:Link title}',", + " href: '${8:/guides/...}',", + " description: '${9:Short description}',", + " },", + " ],", + "}" + ] + }, + "Content listing inline MDX": { + "prefix": "cl-inline", + "scope": "markdown,mdx", + "description": "Inline ContentListings component in a guide MDX file", + "body": [""] + } +} diff --git a/SECURITY.md b/SECURITY.md deleted file mode 120000 index 65dd29a7e19..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1 +0,0 @@ -apps/docs/public/.well-known/security.txt \ No newline at end of file diff --git a/apps/design-system/README.md b/apps/design-system/README.md index 4708b9866f5..893f21e36e6 100644 --- a/apps/design-system/README.md +++ b/apps/design-system/README.md @@ -9,10 +9,10 @@ First, make a copy of _.env.local.example_ and name it _env.local_. Then install ```bash cd apps/design-system pnpm i -pnpm dev:full +pnpm dev ``` -The `dev:full` command runs both the Next.js development server and Contentlayer concurrently, which is recommended for most development workflows. +The `dev` command runs both the Next.js development server and Contentlayer concurrently, which is recommended for most development workflows. ### Alternative commands @@ -20,10 +20,10 @@ You can also run the development server and content watcher separately: ```bash # Run only the Next.js development server -pnpm dev +pnpm dev:next # Run only the content watcher (in a separate terminal shell) -pnpm content:dev +pnpm dev:content ``` Or run the development server from the root directory: @@ -39,14 +39,14 @@ To run both the development server and content watcher from the root directory, pnpm dev:design-system # Run the content watcher (in a separate terminal shell) -pnpm --filter=design-system content:dev +pnpm --filter=design-system dev:content ``` Open [http://localhost:3003](http://localhost:3003) in your browser to see the result. ### Watching for MDX changes -The `dev:full` command automatically watches for changes to MDX files with hot reload. If you're running the `pnpm dev` separately, you'll need to run `pnpm content:dev` in a separate terminal shell to watch for content changes. +The `dev` command automatically watches for changes to MDX files with hot reload. If you're running the `pnpm dev:next` separately, you'll need to run `pnpm dev:content` in a separate terminal shell to watch for content changes. ### Adding components diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 5fd983a108f..0e0dce2ff27 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -159,6 +159,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "admonition-button-split": { + name: "admonition-button-split", + type: "components:example", + registryDependencies: ["admonition","button","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/admonition-button-split")), + source: "", + files: ["registry/default/example/admonition-button-split.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "admonition-description-only": { name: "admonition-description-only", type: "components:example", @@ -368,6 +379,72 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "breadcrumb-demo": { + name: "breadcrumb-demo", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-demo")), + source: "", + files: ["registry/default/example/breadcrumb-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-separator": { + name: "breadcrumb-separator", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-separator")), + source: "", + files: ["registry/default/example/breadcrumb-separator.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-dropdown": { + name: "breadcrumb-dropdown", + type: "components:example", + registryDependencies: ["breadcrumb","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-dropdown")), + source: "", + files: ["registry/default/example/breadcrumb-dropdown.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-ellipsis": { + name: "breadcrumb-ellipsis", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-ellipsis")), + source: "", + files: ["registry/default/example/breadcrumb-ellipsis.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-link": { + name: "breadcrumb-link", + type: "components:example", + registryDependencies: ["breadcrumb"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-link")), + source: "", + files: ["registry/default/example/breadcrumb-link.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "breadcrumb-responsive": { + name: "breadcrumb-responsive", + type: "components:example", + registryDependencies: ["breadcrumb","button","drawer","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/breadcrumb-responsive")), + source: "", + files: ["registry/default/example/breadcrumb-responsive.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "button-demo": { name: "button-demo", type: "components:example", @@ -511,6 +588,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "button-split-dropdown": { + name: "button-split-dropdown", + type: "components:example", + registryDependencies: ["button","dropdown-menu"], + component: React.lazy(() => import("@/registry/default/example/button-split-dropdown")), + source: "", + files: ["registry/default/example/button-split-dropdown.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "calendar-demo": { name: "calendar-demo", type: "components:example", diff --git a/apps/design-system/components/code-fragment.tsx b/apps/design-system/components/code-fragment.tsx index ccf33703720..4eeb7ee7c7c 100644 --- a/apps/design-system/components/code-fragment.tsx +++ b/apps/design-system/components/code-fragment.tsx @@ -94,10 +94,10 @@ export function CodeFragment({ )} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
diff --git a/apps/design-system/components/command-menu.tsx b/apps/design-system/components/command-menu.tsx index 89886d4a59e..e4731f2a055 100644 --- a/apps/design-system/components/command-menu.tsx +++ b/apps/design-system/components/command-menu.tsx @@ -78,7 +78,7 @@ export function CommandMenu({ ...props }: DialogProps) { Search Design System... - + No results found. {docsConfig.sidebarNav.map((group) => ( diff --git a/apps/design-system/components/component-example.tsx b/apps/design-system/components/component-example.tsx index 608d2482687..8447963453b 100644 --- a/apps/design-system/components/component-example.tsx +++ b/apps/design-system/components/component-example.tsx @@ -1,13 +1,7 @@ 'use client' import * as React from 'react' -import { - cn, - Tabs_Shadcn_ as Tabs, - TabsContent_Shadcn_ as TabsContent, - TabsList_Shadcn_ as TabsList, - TabsTrigger_Shadcn_ as TabsTrigger, -} from 'ui' +import { cn, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui' import { // CopyButton, diff --git a/apps/design-system/components/component-preview.tsx b/apps/design-system/components/component-preview.tsx index 8689633cf91..04a5fc30ee1 100644 --- a/apps/design-system/components/component-preview.tsx +++ b/apps/design-system/components/component-preview.tsx @@ -101,10 +101,10 @@ export function ComponentPreview({ )} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
{/*
*/} @@ -146,10 +146,10 @@ export function ComponentPreview({ })} > {showGrid && ( -
+
)} {showDottedGrid && ( -
+
)}
{ComponentPreview}
diff --git a/apps/design-system/components/mdx-components.tsx b/apps/design-system/components/mdx-components.tsx index 0e78f045e92..c7b5f7cfae1 100644 --- a/apps/design-system/components/mdx-components.tsx +++ b/apps/design-system/components/mdx-components.tsx @@ -16,10 +16,10 @@ import { AlertTitle, AspectRatio, cn, - Tabs_Shadcn_ as Tabs, - TabsContent_Shadcn_ as TabsContent, - TabsList_Shadcn_ as TabsList, - TabsTrigger_Shadcn_ as TabsTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' import { Admonition } from 'ui-patterns/admonition' diff --git a/apps/design-system/components/theme-settings.tsx b/apps/design-system/components/theme-settings.tsx index 7b72bd9daab..19abbeef152 100644 --- a/apps/design-system/components/theme-settings.tsx +++ b/apps/design-system/components/theme-settings.tsx @@ -3,7 +3,7 @@ import { useTheme } from 'next-themes' import { useEffect, useState } from 'react' import SVG from 'react-inlinesvg' -import { RadioGroup, RadioGroupLargeItem, singleThemes, Theme } from 'ui' +import { RadioGroup, RadioGroupLargeItem, singleThemes } from 'ui' const ThemeSettings = () => { const [mounted, setMounted] = useState(false) @@ -33,7 +33,7 @@ const ThemeSettings = () => { value={theme} className="flex flex-wrap gap-3" > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( diff --git a/apps/design-system/components/theme-switcher-dropdown.tsx b/apps/design-system/components/theme-switcher-dropdown.tsx index 23b706a9c47..039f00a1911 100644 --- a/apps/design-system/components/theme-switcher-dropdown.tsx +++ b/apps/design-system/components/theme-switcher-dropdown.tsx @@ -13,7 +13,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, singleThemes, - Theme, } from 'ui' const ThemeSwitcherDropdown = () => { @@ -59,7 +58,7 @@ const ThemeSwitcherDropdown = () => { value={theme} onValueChange={(themeValue) => setTheme(themeValue)} > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( {theme.name} diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index 7588d3262c8..43afbfad43f 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -105,7 +105,7 @@ Used for actions that are not as important as the primary action, or for actions -### Only an Icon +### Only an icon Displaying only an Icon in a button. @@ -115,12 +115,28 @@ Displaying only an Icon in a button. -### As Child +### As child Supports slot behavior with `asChild` prop. +### Split with dropdown + +Pair a button with a chevron `DropdownMenu` trigger when there are variations of the same action, or alternative ways to accomplish the same goal. The default or most likely option should be used on the exposed button. + +When secondary actions are related but distinct—not alternatives to the primary action—display the primary action as a button and place the rest in an overflow menu instead. See [Table multiple actions](./table#multiple-actions). + + + +The shared middle border is the tricky part. Do **not** use `border-l-0` on the chevron button — that drops the divider on hover/focus. Instead: + +- Primary: `rounded-r-none` and `hover:z-10` so its border stacks above the chevron on hover. +- Chevron trigger: `rounded-l-none`, `shrink-0`, `px-[4px] py-[5px]`, and `-ml-px` to overlap the adjacent border by one pixel. +- Chevron trigger only: `aria-label` describing the menu (the icon is decorative). + +Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions, also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary when `layout="responsive"`. + ## Accessibility [Keyboard focus](../accessibility#focus-management) is automatically handled: diff --git a/apps/design-system/content/docs/fragments/admonition.mdx b/apps/design-system/content/docs/fragments/admonition.mdx index 8c903e50ab8..3b4221424d0 100644 --- a/apps/design-system/content/docs/fragments/admonition.mdx +++ b/apps/design-system/content/docs/fragments/admonition.mdx @@ -46,6 +46,18 @@ Style the color of the button based upon the _context_ of the Admonition, not it Only ever use the `primary` (green) button `type` on a `default` Admonition. Even then—given that Admonition is an isolated callout—the `primary` button `type` should rarely be used. +### Split button with dropdown + +When a callout offers alternative ways to accomplish the same goal, use a [split button](../components/button#split-with-dropdown) instead of crowding the callout with multiple buttons. For related but distinct actions, use an overflow menu. See [Table multiple actions](../components/table#multiple-actions). + + + +In responsive Admonitions, wrap the pair in `flex w-full @lg:w-auto`, give the primary `flex-1 @lg:flex-none`, and keep the chevron `shrink-0`. + ## Examples -## Responsive +### Responsive Resize your browser to see the button(s) change `layout` based on the Admonition’s width. diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 089349e2223..bad4ec8c6be 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -5,13 +5,13 @@ "type": "module", "scripts": { "preinstall": "npx only-allow pnpm", - "dev": "next dev --turbopack --port 3003", - "dev:full": "concurrently \"pnpm dev\" \"pnpm content:dev\"", + "dev": "run-p --race dev:*", + "dev:next": "next dev --turbopack --port 3003", + "dev:content": "contentlayer2 dev", "build": "pnpm run content:build && pnpm run build:registry && next build --turbopack", "build:registry": "tsx ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", "start": "next start", "lint": "eslint .", - "content:dev": "contentlayer2 dev", "content:build": "contentlayer2 build", "clean": "rimraf node_modules .next .turbo", "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" @@ -57,9 +57,9 @@ "@types/lodash.template": "4.5.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "concurrently": "^8.2.2", "config": "workspace:*", "mdast-util-toc": "^6.1.1", + "npm-run-all": "^4.1.5", "postcss": "catalog:", "rimraf": "^4.1.3", "shiki": "^1.1.7", diff --git a/apps/design-system/registry/default/example/admonition-button-split.tsx b/apps/design-system/registry/default/example/admonition-button-split.tsx new file mode 100644 index 00000000000..3432c96f5c5 --- /dev/null +++ b/apps/design-system/registry/default/example/admonition-button-split.tsx @@ -0,0 +1,60 @@ +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +export default function AdmonitionButtonSplitDemo() { + return ( + + + + + + + + + + )} + + + + ) : null} + {items.slice(-ITEMS_TO_DISPLAY + 1).map((item, index) => ( + + {index > 0 ? : null} + + {item.href ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + + ))} + + + ) +} diff --git a/apps/design-system/registry/default/example/breadcrumb-separator.tsx b/apps/design-system/registry/default/example/breadcrumb-separator.tsx new file mode 100644 index 00000000000..9de9e3583eb --- /dev/null +++ b/apps/design-system/registry/default/example/breadcrumb-separator.tsx @@ -0,0 +1,38 @@ +import { Slash } from 'lucide-react' +import Link from 'next/link' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from 'ui' + +export default function BreadcrumbSeparatorDemo() { + return ( + + + + + Home + + + + + + + + Components + + + + + + + Breadcrumb + + + + ) +} diff --git a/apps/design-system/registry/default/example/button-split-dropdown.tsx b/apps/design-system/registry/default/example/button-split-dropdown.tsx new file mode 100644 index 00000000000..885e1bbebaa --- /dev/null +++ b/apps/design-system/registry/default/example/button-split-dropdown.tsx @@ -0,0 +1,37 @@ +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +export default function ButtonSplitDropdownDemo() { + return ( +
+ + + +
+ ) +} diff --git a/apps/design-system/registry/default/example/copy-empty-states.tsx b/apps/design-system/registry/default/example/copy-empty-states.tsx index 7008b7ef0cf..50f25046cb2 100644 --- a/apps/design-system/registry/default/example/copy-empty-states.tsx +++ b/apps/design-system/registry/default/example/copy-empty-states.tsx @@ -2,7 +2,7 @@ import { Key } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function CopyEmptyStates() { return ( diff --git a/apps/design-system/registry/default/example/drawer-demo.tsx b/apps/design-system/registry/default/example/drawer-demo.tsx index 22ce3dd8566..1208680a3d0 100644 --- a/apps/design-system/registry/default/example/drawer-demo.tsx +++ b/apps/design-system/registry/default/example/drawer-demo.tsx @@ -111,7 +111,7 @@ export default function DrawerDemo() { dataKey="goal" style={ { - fill: 'hsl(var(--foreground-default))', + fill: 'var(--foreground-default)', opacity: 0.9, } as React.CSSProperties } diff --git a/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx index 0d548e96489..9a2196a4669 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-buttons.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PageSection, PageSectionAside, diff --git a/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx index cf33f0e62f3..09a85c5d057 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-demo.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function EmptyStatePresentationalIcon() { return ( diff --git a/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx index ba9e158d59b..a25204ae1ca 100644 --- a/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx +++ b/apps/design-system/registry/default/example/empty-state-presentational-icon.tsx @@ -1,7 +1,7 @@ import { BucketPlus } from 'icons' import { Plus } from 'lucide-react' import { Button } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export default function EmptyStatePresentationalIcon() { return ( diff --git a/apps/design-system/registry/default/example/filter-bar-demo.tsx b/apps/design-system/registry/default/example/filter-bar-demo.tsx index 497a1ddecd8..4bc48b1bfec 100644 --- a/apps/design-system/registry/default/example/filter-bar-demo.tsx +++ b/apps/design-system/registry/default/example/filter-bar-demo.tsx @@ -2,7 +2,7 @@ import { format } from 'date-fns' import { useState } from 'react' import { DateRange } from 'react-day-picker' import { Button, Calendar } from 'ui' -import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns' +import { CustomOptionProps, FilterBar, FilterGroup } from 'ui-patterns/FilterBar' function CustomDatePicker({ onChange, onCancel, search }: CustomOptionProps) { const [date, setDate] = useState( diff --git a/apps/design-system/registry/default/example/page-layout-edge-function.tsx b/apps/design-system/registry/default/example/page-layout-edge-function.tsx index 1a9aeeaa5bc..4377bee9ec1 100644 --- a/apps/design-system/registry/default/example/page-layout-edge-function.tsx +++ b/apps/design-system/registry/default/example/page-layout-edge-function.tsx @@ -150,7 +150,7 @@ const buildMockChartData = (intervalKey: string) => { const EXECUTION_TIME_CHART_CONFIG = { avg_execution_time: { label: 'Average Execution Time', - color: 'hsl(var(--foreground-default))', + color: 'var(--foreground-default)', }, max_execution_time: { label: 'Max Execution Time', @@ -485,7 +485,7 @@ function OverviewPage() { { y: averageExecutionTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} @@ -538,7 +538,7 @@ function OverviewPage() { { y: averageCpuTime, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} @@ -589,7 +589,7 @@ function OverviewPage() { { y: averageMemoryUsage, label: 'average', - stroke: 'hsl(var(--foreground-default))', + stroke: 'var(--foreground-default)', strokeWidth: 1.5, }, ]} diff --git a/apps/design-system/registry/default/example/page-layout-logs-content.tsx b/apps/design-system/registry/default/example/page-layout-logs-content.tsx index 6826cb565fc..8879095879c 100644 --- a/apps/design-system/registry/default/example/page-layout-logs-content.tsx +++ b/apps/design-system/registry/default/example/page-layout-logs-content.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { FilterBar, isGroup, type FilterCondition, type FilterGroup } from 'ui-patterns' +import { FilterBar, isGroup, type FilterCondition, type FilterGroup } from 'ui-patterns/FilterBar' const logs = [ ['10:42:01.129', 'POST /hello-world', '200', '132ms', 'iad1'], diff --git a/apps/design-system/registry/default/example/status-code-demo.tsx b/apps/design-system/registry/default/example/status-code-demo.tsx index 4f41585f901..1b346fb47dc 100644 --- a/apps/design-system/registry/default/example/status-code-demo.tsx +++ b/apps/design-system/registry/default/example/status-code-demo.tsx @@ -1,4 +1,4 @@ -import { StatusCode } from 'ui-patterns' +import { StatusCode } from 'ui-patterns/StatusCode' export default function StatusCodeDemo() { return ( diff --git a/apps/design-system/registry/default/example/tabs-demo.tsx b/apps/design-system/registry/default/example/tabs-demo.tsx index c0e4951f5d6..edf748e9718 100644 --- a/apps/design-system/registry/default/example/tabs-demo.tsx +++ b/apps/design-system/registry/default/example/tabs-demo.tsx @@ -8,20 +8,20 @@ import { CardTitle, Input, Label, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' export default function TabsDemo() { return ( - - - Account - Password - - + + + Account + Password + + Account @@ -43,8 +43,8 @@ export default function TabsDemo() { - - + + Password @@ -66,7 +66,7 @@ export default function TabsDemo() { - - + + ) } diff --git a/apps/design-system/registry/default/example/toc-demo.tsx b/apps/design-system/registry/default/example/toc-demo.tsx index 6764c55119f..f62d944a7aa 100644 --- a/apps/design-system/registry/default/example/toc-demo.tsx +++ b/apps/design-system/registry/default/example/toc-demo.tsx @@ -1,7 +1,13 @@ 'use client' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' -import { AnchorProvider, Toc, TOCItems, TOCScrollArea, type AnchorProviderProps } from 'ui-patterns' +import { + AnchorProvider, + Toc, + TOCItems, + TOCScrollArea, + type AnchorProviderProps, +} from 'ui-patterns/Toc' export default function MultiSelectDemo() { return ( diff --git a/apps/design-system/registry/default/example/toc-single-demo.tsx b/apps/design-system/registry/default/example/toc-single-demo.tsx index bb010262eae..513b3e88c20 100644 --- a/apps/design-system/registry/default/example/toc-single-demo.tsx +++ b/apps/design-system/registry/default/example/toc-single-demo.tsx @@ -1,7 +1,13 @@ 'use client' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' -import { AnchorProvider, Toc, TOCItems, TOCScrollArea, type AnchorProviderProps } from 'ui-patterns' +import { + AnchorProvider, + Toc, + TOCItems, + TOCScrollArea, + type AnchorProviderProps, +} from 'ui-patterns/Toc' export default function MultiSelectDemo() { return ( diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 596c4fbb5a0..161841ca9ca 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -25,6 +25,12 @@ export const examples: Registry = [ registryDependencies: ['admonition'], files: ['example/admonition-button.tsx'], }, + { + name: 'admonition-button-split', + type: 'components:example', + registryDependencies: ['admonition', 'button', 'dropdown-menu'], + files: ['example/admonition-button-split.tsx'], + }, { name: 'admonition-description-only', type: 'components:example', @@ -139,42 +145,42 @@ export const examples: Registry = [ registryDependencies: ['badge'], files: ['example/badge-secondary.tsx'], }, - // { - // name: 'breadcrumb-demo', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-demo.tsx'], - // }, - // { - // name: 'breadcrumb-separator', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-separator.tsx'], - // }, - // { - // name: 'breadcrumb-dropdown', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-dropdown.tsx'], - // }, - // { - // name: 'breadcrumb-ellipsis', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-ellipsis.tsx'], - // }, - // { - // name: 'breadcrumb-link', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-link.tsx'], - // }, - // { - // name: 'breadcrumb-responsive', - // type: 'components:example', - // registryDependencies: ['breadcrumb'], - // files: ['example/breadcrumb-responsive.tsx'], - // }, + { + name: 'breadcrumb-demo', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-demo.tsx'], + }, + { + name: 'breadcrumb-separator', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-separator.tsx'], + }, + { + name: 'breadcrumb-dropdown', + type: 'components:example', + registryDependencies: ['breadcrumb', 'dropdown-menu'], + files: ['example/breadcrumb-dropdown.tsx'], + }, + { + name: 'breadcrumb-ellipsis', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-ellipsis.tsx'], + }, + { + name: 'breadcrumb-link', + type: 'components:example', + registryDependencies: ['breadcrumb'], + files: ['example/breadcrumb-link.tsx'], + }, + { + name: 'breadcrumb-responsive', + type: 'components:example', + registryDependencies: ['breadcrumb', 'button', 'drawer', 'dropdown-menu'], + files: ['example/breadcrumb-responsive.tsx'], + }, { name: 'button-demo', type: 'components:example', @@ -253,6 +259,12 @@ export const examples: Registry = [ registryDependencies: ['button'], files: ['example/button-as-child.tsx'], }, + { + name: 'button-split-dropdown', + type: 'components:example', + registryDependencies: ['button', 'dropdown-menu'], + files: ['example/button-split-dropdown.tsx'], + }, { name: 'calendar-demo', type: 'components:example', diff --git a/apps/design-system/styles/code-block-variables.css b/apps/design-system/styles/code-block-variables.css index f1054071748..a21af6b3751 100644 --- a/apps/design-system/styles/code-block-variables.css +++ b/apps/design-system/styles/code-block-variables.css @@ -18,16 +18,16 @@ [data-theme='light'], .light { --code-token-keyword: #6b35dc; - --code-foreground: hsl(var(--foreground-light) / 1); + --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; - --code-token-parameter: hsl(var(--foreground-light) / 1); + --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; - --code-token-punctuation: hsl(var(--foreground-light) / 1); - --code-token-link: hsl(var(--foreground-light) / 1); - --code-token-number: hsl(var(--foreground-light) / 1); + --code-token-string-expression: #c46a0a; + --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); + --code-token-link: oklch(from var(--foreground-light) l c h / 1); + --code-token-number: oklch(from var(--foreground-light) l c h / 1); --code-token-property: #15593b; --code-highlight-color: #1c1c1c; } diff --git a/apps/docs/.env.development b/apps/docs/.env.development index f5e09eed460..56120419134 100644 --- a/apps/docs/.env.development +++ b/apps/docs/.env.development @@ -9,6 +9,10 @@ NEXT_PUBLIC_BASE_PATH="/docs" # Setting this to true requires certain secret keys NEXT_PUBLIC_IS_PLATFORM="false" +# Base URL for the hosted MCP server. Overrides the DEFAULT_MCP_URL_PLATFORM +# fallback in @ui-patterns/McpUrlBuilder. +NEXT_PUBLIC_MCP_URL="http://localhost:8080/mcp" + # Supabase project containing integration information NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co" NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M" diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 6450a90c812..6a3027b1770 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -197,15 +197,42 @@ Keep code lines short to avoid scrolling. For example, you can split long shell Optionally specify a filename for the codeblock by including it after the opening backticks and language specifier: -```md +````md ```ts environment.ts + ``` +```` Optionally highlight lines by using `mark=${lineNumber}`. -```md +````md ```js mark=12:13 + ``` +```` + +### Content listings + +Overview and index pages use a single `` component for curated link sections such as "Get started", "Next steps", "Examples", or "Resources". Refer to [`storage.data.ts`](data/content-listings/storage.data.ts) and [`storage.mdx`](content/guides/storage.mdx) for a full example. + +**Prompt to add content listings:** + +```text +Add a content listing block for [TOPIC] / [SECTION] (for example, Storage / Examples). +Follow CONTRIBUTING § Content listings in apps/docs. +Copy structure from `storageGetStarted` in apps/docs/data/content-listings/storage.data.ts. +Pick a globally-unique kebab-case id like `[topic]-[section]`. +Run `pnpm test:local lib/content-listings.test.ts` from apps/docs. +``` + +**Manually add content listings:** + +1. Add or update a `ContentListingGroup` export in [`data/content-listings/[topic].data.ts`](data/content-listings/). The `id` field must be globally unique across all listing groups (e.g. `storage-get-started`, not just `get-started`) — it is used both as the lookup key and as the telemetry `listingId`. +2. Place the component inline in guide MDX, for example ``. Use a partial only when the block is reused or gated with `$Show` at the partial level. +3. Run `pnpm test:local lib/content-listings.test.ts` from `apps/docs`. + +Code snippets for manually adding content listings are available in [`.vscode/content-listing.code-snippets`](../../.vscode/content-listing.code-snippets): `cl-data` (data export with namespaced id) and `cl-inline` (MDX component). + ### Footnotes @@ -284,7 +311,7 @@ Don't nest lists more than two deep. 3. List item - List item - List item - + - Overly nested list item ``` diff --git a/apps/docs/app/contributing/content.mdx b/apps/docs/app/contributing/content.mdx index be59011c43b..12d02b26a59 100644 --- a/apps/docs/app/contributing/content.mdx +++ b/apps/docs/app/contributing/content.mdx @@ -20,7 +20,6 @@ For content that requires progressive disclosure: ```mdx { return ( - + + {children} diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx new file mode 100644 index 00000000000..323478ca22e --- /dev/null +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ContentListingGroup, ContentListingItem } from '~/lib/content-listings.schema' +import { + getContentListingById, + getContentListingGroupLabel, + isExternalContentListingHref, +} from '~/lib/content-listings.utils' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import Link from 'next/link' +import { useCallback } from 'react' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { Heading } from 'ui/src/components/CustomHTMLElements' + +const GRID_ITEM_CLASS = { + 2: 'col-span-12 md:col-span-6', + 3: 'col-span-12 md:col-span-4', + 4: 'col-span-12 md:col-span-3', +} as const + +function useContentListingClickHandler(group: ContentListingGroup) { + const sendTelemetryEvent = useSendTelemetryEvent() + const groupLabel = getContentListingGroupLabel(group) + + const trackClick = useCallback( + (item: ContentListingItem) => { + sendTelemetryEvent({ + action: 'docs_content_listing_clicked', + properties: { + targetPath: item.href, + linkTitle: item.title, + ...(groupLabel ? { groupTitle: groupLabel } : {}), + listingId: group.id, + }, + }) + }, + [sendTelemetryEvent, group.id, groupLabel] + ) + + return { trackClick } +} + +function ContentListingGroupHeading({ group }: { group: ContentListingGroup }) { + if (!group.heading) return null + + return {group.heading} +} + +function ContentListingsGroup({ group }: { group: ContentListingGroup }) { + const { trackClick } = useContentListingClickHandler(group) + const isGrid = group.type === 'grid' + const listClassName = isGrid ? 'grid md:grid-cols-12 gap-4' : 'list-disc pl-6 space-y-2' + const gridItemClassName = isGrid ? GRID_ITEM_CLASS[group.columns ?? 3] : undefined + + // Heading stays outside `not-prose` so it inherits the surrounding MDX prose + // typography. The list itself opts out so its explicit Tailwind layout wins. + return ( +
+ +
+ {group.description &&

{group.description}

} +
    + {group.items.map((item) => { + const external = isExternalContentListingHref(item.href) + const key = `${group.id}-${item.href}` + + if (isGrid) { + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + + {item.description} + + +
  • + ) + } + + return ( +
  • + trackClick(item)} + target={external ? '_blank' : undefined} + > + {item.title}: {item.description} + +
  • + ) + })} +
+
+
+ ) +} + +export function ContentListings({ id }: { id: string }) { + const group = getContentListingById(id) + if (!group || !group.items.length) return null + + return ( +
+ +
+ ) +} diff --git a/apps/docs/components/ContentListings/index.tsx b/apps/docs/components/ContentListings/index.tsx new file mode 100644 index 00000000000..461a4ab891b --- /dev/null +++ b/apps/docs/components/ContentListings/index.tsx @@ -0,0 +1 @@ +export { ContentListings } from './ContentListings.client' diff --git a/apps/docs/components/CustomContent.tsx b/apps/docs/components/CustomContent.tsx new file mode 100644 index 00000000000..ed6c727b293 --- /dev/null +++ b/apps/docs/components/CustomContent.tsx @@ -0,0 +1,59 @@ +import { + getCustomContent, + type CustomContent as CustomContentKey, +} from '~/lib/custom-content/getCustomContent' +import type { ReactNode } from 'react' + +import { resolveSharedDataPath } from './SharedData.utils' + +type ValueFor = ReturnType< + typeof getCustomContent<[T]> +>[keyof ReturnType>] + +/** + * A wrapper component to access values from `custom-content.json` within MDX + * files. Mirrors the `getCustomContent` helper used in TSX code, and follows + * the same `data`/path-or-render-function pattern as `SharedData`. + * + * @param data - The `custom-content.json` key to read, e.g. `navigation:logo`. + * @param children - How to access the selected value. If it is a render + * function, it takes the value as a param. If it is a + * string, it takes a path through the value, formatted like + * `a[0].b.c`. If omitted, the value itself is rendered. + * + * @example Render a value inline + * + * + * @example Address a nested field with a path + * light + * + * @example Use a render function for richer output + * + * {(logo) => } + * + */ +function CustomContent({ + data, + children, +}: { + data: T + children?: ((value: ValueFor) => ReactNode) | string +}) { + const result = getCustomContent([data]) + const value = Object.values(result)[0] as ValueFor + + if (typeof children === 'function') { + return children(value) + } + if (typeof children === 'string') { + return resolveSharedDataPath(value, children) as ReactNode + } + + if (value != null && typeof value === 'object') { + return JSON.stringify(value) as unknown as ReactNode + } + + return (value ?? null) as ReactNode +} + +export { CustomContent } diff --git a/apps/docs/components/DocsCoverLogo.tsx b/apps/docs/components/DocsCoverLogo.tsx index 8a66b35ea13..3b56575513f 100644 --- a/apps/docs/components/DocsCoverLogo.tsx +++ b/apps/docs/components/DocsCoverLogo.tsx @@ -1,7 +1,7 @@ 'use client' +import { domAnimation, LazyMotion, m } from 'framer-motion' import React, { type PropsWithChildren } from 'react' -import { LazyMotion, domAnimation, m } from 'framer-motion' const DocsCoverLogo = (props: PropsWithChildren) => { const pathMotionConfig = { @@ -47,7 +47,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => { animate={pathMotionConfig.animate} transition={pathMotionConfig.transition as any} d="M125.25 191.383C167.828 191.383 202.344 156.867 202.344 114.289C202.344 71.7115 167.828 37.1953 125.25 37.1953C82.6724 37.1953 48.1562 71.7115 48.1562 114.289C48.1562 156.867 82.6724 191.383 125.25 191.383Z" - stroke="hsl(var(--foreground-light))" + stroke="var(--foreground-light)" strokeOpacity="0.1" strokeWidth="0.7" strokeMiterlimit="10" @@ -215,7 +215,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => { animate={pathMotionConfig.animate} transition={pathMotionConfig.transition as any} d="M218.024 169.846H32.498" - stroke="hsl(var(--foreground-light))" + stroke="var(--foreground-light)" strokeOpacity="0.1" strokeWidth="0.7" strokeMiterlimit="10" @@ -291,8 +291,8 @@ const DocsCoverLogo = (props: PropsWithChildren) => { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.451 114.82) rotate(90) scale(109.781 0.5)" > - - + + { y2="11.9994" gradientUnits="userSpaceOnUse" > - - + + { y2="204.984" gradientUnits="userSpaceOnUse" > - - + + { y2="13.9993" gradientUnits="userSpaceOnUse" > - - + + { y2="204.984" gradientUnits="userSpaceOnUse" > - - + + { y2="-7.94661" gradientUnits="userSpaceOnUse" > - - - + + + { y2="-3.99932" gradientUnits="userSpaceOnUse" > - - - + + + { y2="199.061" gradientUnits="userSpaceOnUse" > - - - + + + { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.752 114.291) rotate(90) scale(109.271 0.5)" > - - + + { y2="6.31757" gradientUnits="userSpaceOnUse" > - - + + { y2="200" gradientUnits="userSpaceOnUse" > - - + + { y2="199.061" gradientUnits="userSpaceOnUse" > - - - + + + { gradientUnits="userSpaceOnUse" gradientTransform="translate(125.261 59.2344) rotate(90) scale(0.5 263.749)" > - - + + { y2="94.9749" gradientUnits="userSpaceOnUse" > - - - + + + { y2="131.43" gradientUnits="userSpaceOnUse" > - - - + + + { y2="1.99785" gradientUnits="userSpaceOnUse" > - - - + + + +} diff --git a/apps/docs/components/McpCiConfigBlock.utils.ts b/apps/docs/components/McpCiConfigBlock.utils.ts new file mode 100644 index 00000000000..d13c7274d6d --- /dev/null +++ b/apps/docs/components/McpCiConfigBlock.utils.ts @@ -0,0 +1,19 @@ +/** + * Builds the example CI MCP server configuration. Pure and dependency-free so + * it can be shared between the React `` component (Next.js + * bundle) and the build-time markdown-schema handler without pulling in + * `CodeBlock`'s heavier dependencies (Shiki, Twoslash) into the build script. + */ +export function buildMcpCiConfig(remoteUrl = 'https://mcp.supabase.com/mcp') { + return { + mcpServers: { + supabase: { + type: 'http', + url: `${remoteUrl}?project_ref=\${SUPABASE_PROJECT_REF}`, + headers: { + Authorization: 'Bearer ${SUPABASE_ACCESS_TOKEN}', + }, + }, + }, + } +} diff --git a/apps/docs/components/Mermaid.tsx b/apps/docs/components/Mermaid.tsx index 1ffbf346c4c..4b018558c0c 100644 --- a/apps/docs/components/Mermaid.tsx +++ b/apps/docs/components/Mermaid.tsx @@ -6,13 +6,13 @@ interface MermaidProps { export function Mermaid({ chart }: MermaidProps) { const svg = renderMermaidSVG(chart, { - bg: 'hsl(var(--background-default))', - fg: 'hsl(var(--foreground-default))', + bg: 'var(--background-default)', + fg: 'var(--foreground-default)', accent: 'hsl(var(--brand-default))', - muted: 'hsl(var(--foreground-light))', - line: 'hsl(var(--border-strong))', - border: 'hsl(var(--border-strong))', - surface: 'hsl(var(--background-surface-200))', + muted: 'var(--foreground-light)', + line: 'var(--border-strong)', + border: 'var(--border-strong)', + surface: 'var(--background-surface-200)', transparent: true, }) diff --git a/apps/docs/components/Navigation/Navigation.types.ts b/apps/docs/components/Navigation/Navigation.types.ts index b786a96586e..9dfc85e6142 100644 --- a/apps/docs/components/Navigation/Navigation.types.ts +++ b/apps/docs/components/Navigation/Navigation.types.ts @@ -24,6 +24,7 @@ type MenuItem = { level?: string hasLightIcon?: boolean community?: boolean + new?: boolean enabled?: boolean } diff --git a/apps/docs/components/Navigation/NavigationMenu/GlobalMobileMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/GlobalMobileMenu.tsx index 7c9fb2c367a..e08325e5538 100644 --- a/apps/docs/components/Navigation/NavigationMenu/GlobalMobileMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/GlobalMobileMenu.tsx @@ -63,6 +63,7 @@ const AccordionMenuItem = ({ section }: { section: DropdownMenuItem[] }) => { href={item.href} title={item.label} community={item.community} + new={item.new} icon={item.icon} /> ) diff --git a/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx index 627f1dddb6d..3b6c71ed599 100644 --- a/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/GlobalNavigationMenu.tsx @@ -59,7 +59,7 @@ export const useActiveMenuLabel = (menu: typeof GLOBAL_MENU_ITEMS) => { const GlobalNavigationMenu: FC = () => { const activeLabel = useActiveMenuLabel(GLOBAL_MENU_ITEMS) const triggerClassName = - 'h-(--header-height) p-2 bg-transparent border-0 border-b-2 border-transparent font-normal rounded-none text-foreground-light hover:text-foreground data-open:text-foreground! data-radix-collection-item:focus-visible:ring-2 data-radix-collection-item:focus-visible:ring-foreground-lighter data-radix-collection-item:focus-visible:text-foreground h-full focus-visible:rounded-sm shadow-none! outline-hidden transition-all outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600' + 'h-(--header-height) p-2 bg-transparent border-0 border-b-2 border-transparent font-normal rounded-none text-foreground-light hover:bg-transparent hover:text-foreground data-open:bg-transparent! data-open:text-foreground! data-radix-collection-item:focus-visible:ring-2 data-radix-collection-item:focus-visible:ring-foreground-lighter data-radix-collection-item:focus-visible:text-foreground h-full focus-visible:rounded-sm shadow-none! outline-hidden transition-all outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600' return (
@@ -117,6 +117,7 @@ const GlobalNavigationMenu: FC = () => { href={item.href} title={item.label} community={item.community} + new={item.new} icon={item.icon} /> @@ -162,8 +163,9 @@ export const MenuItem = React.forwardRef< React.ComponentPropsWithoutRef<'a'> & { icon?: string community?: boolean + new?: boolean } ->(({ className, title, href = '', icon, community, children, ...props }, ref) => { +>(({ className, title, href = '', icon, community, new: isNew, children, ...props }, ref) => { return ( } {title} {community && Community} + {isNew && New} )} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index a075a475fe6..d6699ade685 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -280,6 +280,13 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ }, ], [ + { + label: 'Server SDK', + icon: 'reference-javascript', + href: '/reference/server' as `/${string}`, + level: 'reference_server', + new: true, + }, { label: 'CLI Commands', icon: 'reference-cli', @@ -1420,6 +1427,10 @@ export const database: NavMenuConstant = { name: 'Logflare', url: '/guides/database/extensions/wrappers/logflare' as `/${string}`, }, + { + name: 'MongoDB', + url: '/guides/database/extensions/wrappers/mongodb' as `/${string}`, + }, { name: 'MSSQL', url: '/guides/database/extensions/wrappers/mssql' as `/${string}`, @@ -2096,6 +2107,7 @@ export const storage: NavMenuConstant = { items: [ { name: 'Fundamentals', url: '/guides/storage/cdn/fundamentals' }, { name: 'Smart CDN', url: '/guides/storage/cdn/smart-cdn' }, + { name: 'Purging Cache', url: '/guides/storage/cdn/purge-cdn-cache' }, { name: 'Metrics', url: '/guides/storage/cdn/metrics' }, ], }, @@ -2715,6 +2727,14 @@ export const platform: NavMenuConstant = { name: 'Restore to a new project', url: '/guides/platform/clone-project', }, + { + name: 'Project Pausing', + url: '/guides/platform/free-project-pausing' as `/${string}`, + }, + { + name: 'Delete Project', + url: '/guides/platform/delete-project', + }, { name: 'Single Sign-On', url: '/guides/platform/sso', @@ -3335,6 +3355,17 @@ export const reference_javascript_v2 = { }, } +export const reference_server_v1 = { + icon: 'reference-javascript', + title: 'Server', + url: '/reference/server', + parent: '/reference', + pkg: { + name: '@supabase/server', + repo: 'https://github.com/supabase/server', + }, +} + // TODO: How to? export const reference_dart_v1 = { icon: 'reference-dart', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 5b51b704c83..73bcfdc175c 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -27,6 +27,7 @@ enum MenuId { AiTools = 'ai_tools', LocalDevelopment = 'local_development', Contributing = 'contributing', + RefServerV1 = 'reference_server_v1', RefJavaScriptV1 = 'reference_javascript_v1', RefJavaScriptV2 = 'reference_javascript_v2', RefDartV1 = 'reference_dart_v1', @@ -147,6 +148,11 @@ const menus: Menu[] = [ id: MenuId.Deployment, type: 'guide', }, + { + id: MenuId.RefServerV1, + type: 'reference', + path: '/reference/server', + }, { id: MenuId.RefJavaScriptV1, type: 'reference', diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 7c2101cd27b..2ca75620376 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -10,7 +10,8 @@ import Link from 'next/link' import type { FC } from 'react' import { memo, useState } from 'react' import { Button, buttonVariants, cn } from 'ui' -import { AuthenticatedDropdownMenu, CommandMenuTriggerInput } from 'ui-patterns' +import { AuthenticatedDropdownMenu } from 'ui-patterns/AuthenticatedDropdownMenu' +import { CommandMenuTriggerInput } from 'ui-patterns/CommandMenu' import { getCustomContent } from '../../../lib/custom-content/getCustomContent' import GlobalNavigationMenu from './GlobalNavigationMenu' @@ -47,6 +48,7 @@ const TopNavBar: FC = () => {
Search @@ -96,13 +98,7 @@ const HeaderLogo = memo(() => { const { navigationLogo } = getCustomContent(['navigation:logo']) return ( - + { setTheme(value) }} > - {themes + {singleThemes .filter((x) => x.value === 'light' || x.value === 'dark' || x.value === 'system') - .map((theme: Theme) => ( + .map((theme) => ( {theme.name} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index 364af234717..6b88f8ae343 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -6,16 +6,16 @@ import { Button_Shadcn_ as Button, cn, Command, - CommandGroup as CommandGroup, + CommandGroup, CommandInput, - CommandItem as CommandItem, - CommandList as CommandList, + CommandItem, + CommandList, Popover, PopoverContent, PopoverTrigger, ScrollArea, } from 'ui' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' export interface ComboBoxOption { id: string diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index 5d1b080bde7..82f81048105 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -29,6 +29,7 @@ import { useProjectsInfiniteQuery, } from '~/lib/fetch/projects-infinite' import { retrieve, storeOrRemoveNull } from '~/lib/storage' +import { useSendTelemetryEvent } from '~/lib/telemetry' import { useOnLogout } from '~/lib/userAuth' import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common' import { Check, Copy } from 'lucide-react' @@ -389,12 +390,13 @@ function VariableView({ variable, className }: { variable: Variable; className?: } const { copied, handleCopy } = useCopy() + const sendTelemetryEvent = useSendTelemetryEvent() return ( <>
{ + handleCopy() + sendTelemetryEvent({ + action: 'docs_project_config_variables_copy_button_clicked', + properties: { variable }, + }) + }} aria-label="Copy" > - {copied ? : } + {copied ? : }
@@ -445,7 +453,7 @@ function LoginHint({ variable }: { variable: Variable }) { if (isUserLoading || isLoggedIn) return null return ( -

+

To get your {prettyFormatVariable[variable]},{' '} = Object.fromEntries( ) // Input-parameter columns (only shown in the full/raw table). -export const THROUGHPUT_PARAM_HEADINGS = ['Filters', 'RLS', 'Connected clients'] as const +export const THROUGHPUT_PARAM_HEADINGS = ['RLS', 'Connected clients'] as const // Result columns (shown in both the current-selection table and the raw table). export const THROUGHPUT_METRIC_HEADINGS = [ diff --git a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx index de5402b9d3b..7f13c8bb837 100644 --- a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx +++ b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx @@ -22,49 +22,37 @@ import { } from './RealtimeLimitsEstimator.constants' export default function RealtimeLimitsEstimater({}) { - const findTableValue = ({ computeAddOn, filters, rls, concurrency }) => { + const findTableValue = ({ computeAddOn, rls, concurrency }) => { return throughputTable.find( - (l) => - l.computeAddOn === computeAddOn && - l.filters === filters && - l.rls === rls && - l.concurrency === concurrency + (l) => l.computeAddOn === computeAddOn && l.rls === rls && l.concurrency === concurrency ) } const [computeAddOn, setComputeAddOn] = useState('micro') - const [filters, setFilters] = useState(false) const [rls, setRLS] = useState(false) const [concurrency, setConcurrency] = useState(500) - const [limits, setLimits] = useState(findTableValue({ computeAddOn, filters, rls, concurrency })) + const [limits, setLimits] = useState(findTableValue({ computeAddOn, rls, concurrency })) const [expandPreview, setExpandPreview] = useState(false) const handleComputeAddOnSelection = (val) => { setComputeAddOn(val) setConcurrency(500) - setLimits(findTableValue({ computeAddOn: val, filters, rls, concurrency: 500 })) - } - - const handleFiltersSelection = (value) => { - const val = value.toLowerCase() === 'true' - setFilters(val) - setConcurrency(500) - setLimits(findTableValue({ computeAddOn, filters: val, rls, concurrency: 500 })) + setLimits(findTableValue({ computeAddOn: val, rls, concurrency: 500 })) } const handleRLSSelection = (value) => { const val = value.toLowerCase() === 'true' setRLS(val) setConcurrency(500) - setLimits(findTableValue({ computeAddOn, filters, rls: val, concurrency: 500 })) + setLimits(findTableValue({ computeAddOn, rls: val, concurrency: 500 })) } const handleConcurrencySelection = (value) => { const val = parseInt(value) setConcurrency(val) - setLimits(findTableValue({ computeAddOn, filters, rls, concurrency: val })) + setLimits(findTableValue({ computeAddOn, rls, concurrency: val })) } return ( @@ -86,18 +74,6 @@ export default function RealtimeLimitsEstimater({}) {

-
- - -
({
setIsPopoverOpen(!isPopoverOpen)} > diff --git a/apps/studio/components/grid/components/editor/TimeEditor.tsx b/apps/studio/components/grid/components/editor/TimeEditor.tsx index b092ccd2b81..59cfcfdbd2a 100644 --- a/apps/studio/components/grid/components/editor/TimeEditor.tsx +++ b/apps/studio/components/grid/components/editor/TimeEditor.tsx @@ -42,7 +42,7 @@ function BaseEditor({ return ( { + if (!hasMountedRef.current) { + hasMountedRef.current = true + return + } + snap.setPage(1) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]) + // [Joshen] One to revisit if we can consolidate this and the main return statement if (isForeignTableSelected) { return ( diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 50e8bb81595..afced4818d5 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -56,8 +56,8 @@ export const ForeignKeyFormatter = (props: Props) => { foreignKeyColumn?.format === 'bytea' && !!value ? convertByteaToHex(value) : value return ( -
- +
+ {formattedValue === null ? : formattedValue} {isLoading && formattedValue !== null && ( @@ -86,6 +86,7 @@ export const ForeignKeyFormatter = (props: Props) => { { e.preventDefault() diff --git a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx index f45858e7ca5..f72d3ce51b5 100644 --- a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx +++ b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx @@ -122,39 +122,41 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee :

- - rowIdx: number - row: SupaRow - }) => { - selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx } - }} - onCellDoubleClick={(_, e) => { - e.preventDefault() - e.stopPropagation() - }} - renderers={{ - noRowsFallback: ( -
- {isLoading && ( -
- -
- )} - {isError && ( -

- Failed to find referencing row: {error.message} -

- )} - {isSuccess &&

No results were returned

} -
- ), - }} - /> +
+ + rowIdx: number + row: SupaRow + }) => { + selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx } + }} + onCellDoubleClick={(_, e) => { + e.preventDefault() + e.stopPropagation() + }} + renderers={{ + noRowsFallback: ( +
+ {isLoading && ( +
+ +
+ )} + {isError && ( +

+ Failed to find referencing row: {error.message} +

+ )} + {isSuccess &&

No results were returned

} +
+ ), + }} + /> +
({ } as const return ( -
+
diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index d5ba2b094b7..9544be6d7ef 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -21,6 +21,7 @@ import { useOnRowsChange } from './Grid.utils' import { GridError } from './GridError' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' +import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils' import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils' import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -166,7 +167,7 @@ export const Grid = memo( // Check if this cell has pending changes const isDirty = tableEditorSnap.hasPendingCellChange( snap.table.id, - rowIdentifiers, + getStableRowIdentifiers(row, rowIdentifiers), col.key ) return isDirty ? 'rdg-cell--dirty' : undefined diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 8edb10cd8d6..ca19a3fea68 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -3,6 +3,7 @@ import { RowsChangeData } from 'react-data-grid' import { toast } from 'sonner' import { useTableRowOperations } from '../../hooks/useTableRowOperations' +import { getStableRowIdentifiers } from '../../utils/queueOperationUtils' import { SupaRow } from '@/components/grid/types' import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { DocsButton } from '@/components/ui/DocsButton' @@ -33,7 +34,7 @@ export function useOnRowsChange(rows: SupaRow[]) { const identifiers = {} as Dictionary - isTableLike(snap.originalTable) && + if (isTableLike(snap.originalTable)) { snap.originalTable.primary_keys.forEach((column) => { const col = snap.originalTable.columns.find((c) => c.name === column.name) identifiers[column.name] = @@ -41,8 +42,10 @@ export function useOnRowsChange(rows: SupaRow[]) { ? convertByteaToHex(previousRow[column.name]) : previousRow[column.name] }) + } + const stableIdentifiers = getStableRowIdentifiers(previousRow, identifiers) - if (Object.keys(identifiers).length === 0) { + if (Object.keys(stableIdentifiers).length === 0) { return toast('Unable to update row as table has no primary keys', { description: (
@@ -62,7 +65,7 @@ export function useOnRowsChange(rows: SupaRow[]) { tableId: snap.table.id, table: snap.originalTable, row: previousRow, - rowIdentifiers: identifiers, + rowIdentifiers: stableIdentifiers, columnName: changedColumn, oldValue: previousRow[changedColumn], newValue: rowData[changedColumn], diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index d18894c8208..955dd170d9b 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' +import { ChevronDown } from 'lucide-react' import { useCallback } from 'react' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { isFilterRelatedError } from './GridError.utils' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import { useTableSort } from '@/components/grid/hooks/useTableSort' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { HighCostError } from '@/components/ui/HighQueryCost' import { InlineLink } from '@/components/ui/InlineLink' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -53,6 +54,12 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const isForeignTableMissingVaultKeyError = isForeignTable && error?.message?.includes('query vault failed') + const isIcebergUnauthorizedError = + isForeignTable && + error?.message.includes('iceberg error') && + error?.message.includes('403 Forbidden') && + error?.message.includes('Invalid Compact JWS') + const hasActiveFilters = filters.length > 0 const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message) @@ -79,6 +86,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { return } else if (isInvalidOrderingOperatorError) { return + } else if (isIcebergUnauthorizedError) { + return } return @@ -165,6 +174,30 @@ const InvalidOrderingOperatorError = ({ error }: { error: ResponseError }) => { ) } +const IcebergUnauthorizedError = ({ error }: { error: ResponseError }) => { + const { ref } = useParams() + + return ( + +

+ The API key from your project that's used to retrieve data from your foreign table is either + incorrect or missing. Verify the API key (token) in your{' '} + Iceberg Bucket. + Alternatively, you can also verify the token value in your{' '} + + wrapper's settings + {' '} + or in Vault. +

+ +
+ ) +} + const GeneralError = ({ error }: { error: ResponseError }) => { const { filters } = useTableFilter() @@ -183,3 +216,19 @@ const GeneralError = ({ error }: { error: ResponseError }) => { ) } + +const ExpandError = ({ error }: { error: ResponseError }) => { + return ( + + +
+ View error + +
+
+ + {error.message} + +
+ ) +} diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx index 727ffb603fa..2553eefde32 100644 --- a/apps/studio/components/grid/components/header/ExportDialog.tsx +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -10,12 +10,12 @@ import { DialogSection, DialogSectionSeparator, DialogTitle, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { Filter, Sort, SupaTable } from '@/components/grid/types' @@ -100,12 +100,12 @@ pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${table done via the following command that you can run in your terminal:

- - - As CSV - As SQL - - + + + As CSV + As SQL + + - - + + - - + +

You will be prompted for your database password, and the output file{' '} diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index e264ec62670..88e4e0ff15b 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -21,6 +21,7 @@ import { formatRowsForCSV, hydrateTruncatedRows } from './Header.utils' import { SortPopover } from './sort/SortPopover' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' import { useTableSort } from '@/components/grid/hooks/useTableSort' +import type { SupaRow } from '@/components/grid/types' import { GridHeaderActions } from '@/components/interfaces/TableGridEditor/GridHeaderActions' import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils' @@ -46,10 +47,16 @@ import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export type HeaderProps = { customHeader: ReactNode isRefetching: boolean + rows?: SupaRow[] tableQueriesEnabled?: boolean } -export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true }: HeaderProps) => { +export const Header = ({ + customHeader, + isRefetching, + rows, + tableQueriesEnabled = true, +}: HeaderProps) => { useInitializeFiltersFromUrl() useSyncFiltersToUrl() @@ -80,7 +87,7 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true

{customHeader}
) : snap.selectedRows.size > 0 ? (
- +
) : (
{ +const RowHeader = ({ rows: visibleRows, tableQueriesEnabled = true }: RowHeaderProps) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined @@ -194,7 +202,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { const onRowsDelete = () => { const rowIdxs = Array.from(snap.selectedRows) as number[] - const rows = allRows.filter((x) => rowIdxs.includes(x.idx)) + const rows = (visibleRows ?? allRows).filter((x) => rowIdxs.includes(x.idx)) deleteRows({ rows, diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx index c96808d761f..07a5a4aff64 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx @@ -12,7 +12,7 @@ import { isGroup, SerializableFilterProperty, updateGroupAtPath, -} from 'ui-patterns' +} from 'ui-patterns/FilterBar' import { columnToFilterProperty } from './FilterPopoverNew.utils' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts index 78a28c587e3..2dfff4c7d0b 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.utils.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns' -import type { FilterProperty, OperatorDefinition } from 'ui-patterns' +import type { FilterProperty, OperatorDefinition } from 'ui-patterns/FilterBar' import type { Filter, SupaColumn } from '@/components/grid/types' import { diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx index 0be00fd5cd4..350898ed730 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -290,7 +290,7 @@ export const SortPopoverPrimitive = ({ asChild variant="dashed" iconRight={} - className="sb-grid-dropdown__item-trigger" + className="my-1" data-testid="table-editor-pick-column-to-sort-button" > Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by diff --git a/apps/studio/components/grid/utils/common.ts b/apps/studio/components/grid/utils/common.ts index 7579d346273..f7e2ca1ab60 100644 --- a/apps/studio/components/grid/utils/common.ts +++ b/apps/studio/components/grid/utils/common.ts @@ -1,5 +1,5 @@ export function formatClipboardValue(value: unknown) { - if (!value) return '' + if (value == null) return '' if (typeof value === 'object') { return JSON.stringify(value) } diff --git a/apps/studio/components/grid/utils/queueConflictResolution.test.ts b/apps/studio/components/grid/utils/queueConflictResolution.test.ts index c8ffab9ac08..00b3bfa6438 100644 --- a/apps/studio/components/grid/utils/queueConflictResolution.test.ts +++ b/apps/studio/components/grid/utils/queueConflictResolution.test.ts @@ -539,6 +539,39 @@ describe('upsertOperation', () => { expect(result.operations[0]).toEqual(otherOp) }) + test('should remove primary key edit when reverted using original row identifiers', () => { + const existingOp: QueuedOperation = { + id: 'edit_cell_content:1:id:id:1', + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + timestamp: Date.now() - 1000, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: mockTable, + }, + } + + const operations = [existingOp] + const newOperation: NewEditCellContentOperation = { + type: QueuedOperationType.EDIT_CELL_CONTENT, + tableId: 1, + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 3, + newValue: 1, + table: mockTable, + }, + } + + const result = upsertOperation(operations, newOperation) + + expect(result.operations).toHaveLength(0) + }) + test('should remove operation when number oldValue matches string newValue', () => { const existingOp: QueuedOperation = { id: 'edit_cell_content:1:age:id:1', diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index c250b566f86..325bd18a413 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -1,11 +1,14 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import type { SupaRow } from '../types' import { formatGridDataWithOperationValues, generateTableChangeKey, + getStableRowIdentifiers, + queueRowDeletesWithOptimisticUpdate, rowMatchesIdentifiers, } from './queueOperationUtils' +import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { QueuedOperationType, type NewAddRowOperation, @@ -152,6 +155,20 @@ describe('rowMatchesIdentifiers', () => { }) }) +describe('stable queued row identifiers', () => { + test('should use original row identifiers when present', () => { + const row = { idx: 0, id: 3, __originalRowIdentifiers: { id: 1 } } + + expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 1 }) + }) + + test('should fall back to current identifiers when original row identifiers are not present', () => { + const row = { idx: 0, id: 3 } + + expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 3 }) + }) +}) + describe('formatGridDataWithOperationValues', () => { const makeRow = (idx: number, data: Record = {}): SupaRow => ({ idx, @@ -217,7 +234,7 @@ describe('formatGridDataWithOperationValues', () => { }) const result = formatGridDataWithOperationValues({ operations: [op], rows }) - expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' }) + expect(result[0]).toMatchObject({ idx: 0, id: 1, name: 'Updated' }) expect(result[1]).toEqual(rows[1]) }) @@ -448,4 +465,106 @@ describe('formatGridDataWithOperationValues', () => { const result = formatGridDataWithOperationValues({ operations: [op], rows }) expect(result[0].name).toBe('Updated') }) + + test('should preserve row identity after a primary key edit', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' })] + const idEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: {} as any, + }, + }) + const nameEdit = makeEditOp({ + id: 'edit_cell_content:1:name:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'name', + oldValue: 'Alice', + newValue: 'Updated Alice', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ operations: [idEdit, nameEdit], rows }) + + expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Updated Alice' }) + expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 }) + }) + + test('should keep two rows distinct when one row takes another row primary key value', () => { + const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] + const aliceIdEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:1', + payload: { + rowIdentifiers: { id: 1 }, + columnName: 'id', + oldValue: 1, + newValue: 3, + table: {} as any, + }, + }) + const bobIdEdit = makeEditOp({ + id: 'edit_cell_content:1:id:id:2', + payload: { + rowIdentifiers: { id: 2 }, + columnName: 'id', + oldValue: 2, + newValue: 1, + table: {} as any, + }, + }) + const bobNameEdit = makeEditOp({ + id: 'edit_cell_content:1:name:id:2', + payload: { + rowIdentifiers: { id: 2 }, + columnName: 'name', + oldValue: 'Bob', + newValue: 'Updated Bob', + table: {} as any, + }, + }) + + const result = formatGridDataWithOperationValues({ + operations: [aliceIdEdit, bobIdEdit, bobNameEdit], + rows, + }) + + expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Alice' }) + expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 }) + expect(result[1]).toMatchObject({ idx: 1, id: 1, name: 'Updated Bob' }) + expect(getStableRowIdentifiers(result[1], { id: result[1].id })).toEqual({ id: 2 }) + }) +}) + +describe('queueRowDeletesWithOptimisticUpdate', () => { + test('should queue pending add row deletes with the temp row as original row', () => { + const queueOperation = vi.fn() + const row = { idx: -100, __tempId: '-100', name: 'New Row' } as SupaRow + + queueRowDeletesWithOptimisticUpdate({ + rows: [row], + table: { + id: 1, + schema: 'public', + name: 'users', + entity_type: ENTITY_TYPE.TABLE, + primary_keys: [{ name: 'id' }], + } as Parameters[0]['table'], + queueOperation, + projectRef: 'project-ref', + }) + + expect(queueOperation).toHaveBeenCalledWith({ + type: QueuedOperationType.DELETE_ROW, + tableId: 1, + payload: expect.objectContaining({ + rowIdentifiers: { id: undefined, __tempId: '-100' }, + originalRow: row, + }), + }) + }) }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index 3684886601c..9c19b4328b4 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -1,5 +1,6 @@ import { isPendingAddRow, PendingAddRow, SupaRow } from '../types' import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types' +import { isObject } from '@/lib/helpers' import { EditCellContentOperation, NewQueuedOperation, @@ -8,6 +9,24 @@ import { } from '@/state/table-editor-operation-queue.types' import type { Dictionary } from '@/types' +// Client-only marker that preserves the original SQL WHERE identifiers after PK edits. +const ORIGINAL_ROW_IDENTIFIERS_KEY = '__originalRowIdentifiers' + +export function getStableRowIdentifiers( + row: Dictionary, + fallbackIdentifiers: Dictionary +): Dictionary { + const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY] + return { ...(isObject(identifiers) ? identifiers : fallbackIdentifiers) } +} + +function withOriginalRowIdentifiers( + row: T, + rowIdentifiers: Dictionary +): T { + return { ...row, [ORIGINAL_ROW_IDENTIFIERS_KEY]: { ...rowIdentifiers } } +} + interface EditCellKeyOperation extends Omit< EditCellContentOperation, 'payload' | 'id' | 'timestamp' @@ -59,6 +78,14 @@ export function rowMatchesIdentifiers( return identifierEntries.every(([key, value]) => row[key] === value) } +function rowMatchesOperationIdentifiers( + row: Dictionary, + rowIdentifiers: Dictionary +): boolean { + const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY] + return rowMatchesIdentifiers(isObject(identifiers) ? identifiers : row, rowIdentifiers) +} + export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary): SupaRow[] { return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers)) } @@ -86,8 +113,8 @@ export function queueCellEditWithOptimisticUpdate({ newValue, enumArrayColumns, }: QueueCellEditParams) { - // Updated row identifiers to include __tempId for pending add rows so edits merge into ADD_ROW operation - const rowIdentifiers: Dictionary = { ...callerRowIdentifiers } + // Pending add rows use __tempId so edits merge into the ADD_ROW operation. + const rowIdentifiers = getStableRowIdentifiers(row, callerRowIdentifiers) if (isPendingAddRow(row)) { rowIdentifiers.__tempId = row.__tempId } @@ -151,9 +178,14 @@ export const formatGridDataWithOperationValues = ({ operations.forEach((op) => { if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { const { rowIdentifiers, columnName, newValue } = op.payload - const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + const rowIdx = formattedRows.findIndex((row) => + rowMatchesOperationIdentifiers(row, rowIdentifiers) + ) if (rowIdx !== -1) { - formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue } + formattedRows[rowIdx] = withOriginalRowIdentifiers( + { ...formattedRows[rowIdx], [columnName]: newValue }, + rowIdentifiers + ) } } else if (op.type === QueuedOperationType.ADD_ROW) { const { tempId, rowData } = op.payload @@ -176,9 +208,14 @@ export const formatGridDataWithOperationValues = ({ } } else if (op.type === QueuedOperationType.DELETE_ROW) { const { rowIdentifiers } = op.payload - const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers)) + const rowIdx = formattedRows.findIndex((row) => + rowMatchesOperationIdentifiers(row, rowIdentifiers) + ) if (rowIdx !== -1) { - formattedRows[rowIdx] = { ...formattedRows[rowIdx], __isDeleted: true } + formattedRows[rowIdx] = withOriginalRowIdentifiers( + { ...formattedRows[rowIdx], __isDeleted: true }, + rowIdentifiers + ) } } }) @@ -225,12 +262,16 @@ export function queueRowDeletesWithOptimisticUpdate({ table.primary_keys.forEach((pk) => { rowIdentifiers[pk.name] = row[pk.name] }) + const stableRowIdentifiers = getStableRowIdentifiers(row, rowIdentifiers) + if (isPendingAddRow(row)) { + stableRowIdentifiers.__tempId = row.__tempId + } queueOperation({ type: QueuedOperationType.DELETE_ROW, tableId: table.id, payload: { - rowIdentifiers, + rowIdentifiers: stableRowIdentifiers, originalRow: row, table, }, diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index b667236ecf7..10c8f7576f2 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -9,7 +9,8 @@ import { TableCell, TableRow, } from 'ui' -import { ShimmeringLoader, TimestampInfo } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { APIKeyDeleteDialog } from './APIKeyDeleteDialog' import { ApiKeyPill } from './ApiKeyPill' diff --git a/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx b/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx index 2e4e591c63c..bb22c34e828 100644 --- a/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx +++ b/apps/studio/components/interfaces/APIKeys/CreateNewAPIKeysButton.tsx @@ -66,7 +66,10 @@ export const CreateNewAPIKeysButton = () => { This will create a default publishable key and a default secret key both named{' '} default. These keys are required - to connect your application to your Supabase project. + to connect your application to your Supabase project. Your existing legacy API keys ( + anon and{' '} + service_role) are not affected and + remain valid until you disable them in a separate step. {error && ( diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx index e3d29980549..fdb6dcb24f8 100644 --- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx @@ -13,7 +13,8 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { APIKeyRow } from './APIKeyRow' import { CreatePublishableAPIKeyDialog } from './CreatePublishableAPIKeyDialog' diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index aca9bef5988..e359a35113f 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -35,15 +35,15 @@ function useLastSeen({ projectRef, enabled }: { projectRef: string; enabled?: bo } { const now = useRef(new Date()).current - const query = useLogsQuery( + const query = useLogsQuery({ + enabled, projectRef, - { + initialParams: { iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), iso_timestamp_end: now.toISOString(), sql: "-- last-used-secret-api-keys\nSELECT unix_millis(max(timestamp)) as timestamp, apikey.`hash` FROM edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as request cross join unnest(request.sb) as sb cross join unnest(sb.apikey) as sbapikey cross join unnest(sbapikey.apikey) as apikey WHERE apikey.error is null and apikey.`hash` is not null and apikey.prefix like 'sb_secret_%' GROUP BY apikey.`hash`", }, - enabled - ) + }) return useMemo(() => { if (query.isLoading || !query.logData) { diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index d5ea3576b33..e18648d1cc6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -21,7 +21,7 @@ import { filterAndSortTokens, handleSortChange } from './AccessToken.utils' import { RowLoading } from './AccessTokenTable/RowLoading' import { TableContainer } from './AccessTokenTable/TableContainer' import { ExpiresCell, LastUsedCell, TokenNameCell } from './AccessTokenTable/TokenCells' -import AlertError from '@/components/ui/AlertError' +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 { useTrack } from '@/lib/telemetry/track' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx index c50888afe70..2046c267ba3 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { useGroupedPermissions } from '../hooks/useGroupedPermissions' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx index 47972387791..fb289f932ed 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenDialog.tsx @@ -24,7 +24,7 @@ import { SelectValue, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index d2b90f9c91d..21cd176dbcd 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -17,7 +17,7 @@ import { SheetHeader, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { CUSTOM_EXPIRY_VALUE, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx index 44c8570e811..a902a92966a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx @@ -22,7 +22,7 @@ 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 { AlertError } from '@/components/ui/AlertError' import { ScopedAccessToken, useScopedAccessTokensQuery, diff --git a/apps/studio/components/interfaces/Account/AuditLogs.tsx b/apps/studio/components/interfaces/Account/AuditLogs.tsx index 017640064cb..0471351a4df 100644 --- a/apps/studio/components/interfaces/Account/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Account/AuditLogs.tsx @@ -12,7 +12,7 @@ import { filterByProjects, sortAuditLogs } from './AuditLogs.utils' import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import Table from '@/components/to-be-cleaned/Table' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' import { diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 2307ecc7206..2456d57068d 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -29,6 +29,7 @@ import { } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { parseRedirectMessage } from './AccountIdentities.utils' import { ChangeEmailAddressForm, GitHubChangeEmailAddress, @@ -68,7 +69,7 @@ export const AccountIdentities = () => { const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState() const [linkingProviderId, setLinkingProviderId] = useState() - const [, message] = router.asPath.split('#message=') + const message = parseRedirectMessage(router.asPath) const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => { return !identities.some( (identity) => identity.provider === provider.authProvider || identity.provider === provider.id @@ -119,7 +120,7 @@ export const AccountIdentities = () => { } useEffect(() => { - if (message) toast.success(message.replaceAll('+', ' ')) + if (message) toast.success(message) }, [message]) return ( diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts new file mode 100644 index 00000000000..fffe0612f40 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { parseRedirectMessage } from './AccountIdentities.utils' + +describe('parseRedirectMessage', () => { + it('drops the trailing sb marker and decodes + as spaces', () => { + expect( + parseRedirectMessage( + '/account/me#message=Confirmation+link+accepted.+Please+proceed+to+confirm+link+sent+to+the+other+email&sb=' + ) + ).toBe('Confirmation link accepted. Please proceed to confirm link sent to the other email') + }) + + it('returns undefined when there is no hash', () => { + expect(parseRedirectMessage('/account/me')).toBeUndefined() + }) + + it('returns undefined when the fragment has no message key', () => { + expect(parseRedirectMessage('/account/me#sb=')).toBeUndefined() + }) + + it('finds message even when it is not the first fragment param', () => { + expect(parseRedirectMessage('/account/me#sb=&message=Hi+there')).toBe('Hi there') + }) + + it('preserves a literal + via percent-encoding', () => { + expect(parseRedirectMessage('/account/me#message=a%2Bb&sb=')).toBe('a+b') + }) +}) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts new file mode 100644 index 00000000000..842c102db1c --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts @@ -0,0 +1,2 @@ +export const parseRedirectMessage = (asPath: string) => + new URLSearchParams(asPath.split('#')[1] ?? '').get('message') ?? undefined diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 455dc84916e..6037e19d3b0 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -15,7 +15,6 @@ import { SelectValue, Separator, singleThemes, - Theme, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -58,7 +57,7 @@ export const ThemeSettings = () => { value={theme} className="grid grid-cols-2 gap-4" > - {singleThemes.map((theme: Theme) => ( + {singleThemes.map((theme) => ( { )} {isSuccess && ( <> - {data.totp.length === 1 && ( - - - - We recommend configuring two authenticator apps across different devices - - - The two authenticator apps will serve as a backup for each other. - - - )}
{data.totp.map((factor) => { return ( diff --git a/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx b/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx index 5eeb1be51c7..06341d945d4 100644 --- a/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx +++ b/apps/studio/components/interfaces/Advisors/AdvisorRuleItem.tsx @@ -12,15 +12,15 @@ import { CollapsibleContent, CollapsibleTrigger, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LintInfo } from '../Linter/Linter.constants' import { generateRuleText } from './AdvisorRules.utils' import { CreateRuleSheet } from './CreateRuleSheet' import { DisableRuleModal } from './DisableRuleModal' import { EnableRuleModal } from './EnableRuleModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DocsButton } from '@/components/ui/DocsButton' import { useLintRuleDeleteMutation } from '@/data/lint/delete-lint-rule-mutation' diff --git a/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx b/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx index 174aa7dc054..ceef2a9f472 100644 --- a/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx +++ b/apps/studio/components/interfaces/Advisors/CreateRuleSheet.tsx @@ -27,7 +27,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx index 6a3f0da6607..7b396f3884d 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import type { ReactNode } from 'react' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { AuthorizeRequesterDetails, diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx index 76c592f511b..26a0b03bd98 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' import type { ResourceError } from '@/data/api-authorization/api-authorization-query' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx index 932f837d994..047cc72cf01 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx @@ -14,7 +14,8 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema' import { diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index aaf83f38c76..df6e42f282e 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx index a28e51b7e20..26b03f24797 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Card, CardContent } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx index 6e410076842..4177044763e 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx @@ -1,9 +1,9 @@ import { cn, CommandList } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' import { TextHighlighter } from 'ui-patterns/CommandMenu' import { CommandMenuGroup } from 'ui-patterns/CommandMenu/internal/CommandMenuGroup' import { CommandMenuItem } from 'ui-patterns/CommandMenu/internal/CommandMenuItem' import type { IActionCommand, IRouteCommand } from 'ui-patterns/CommandMenu/internal/types' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' export interface SearchResult { id: string diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx index 29e4c0b8898..87fc8c7d932 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx @@ -130,7 +130,7 @@ export function useCreateCommands(options?: CommandOptions) { { id: 'create-rls-policy', name: 'Create RLS Policy', - route: `/project/${ref}/auth/policies?new=true`, + route: `/project/${ref}/database/policies?new=true`, icon: () => , }, ...(IS_PLATFORM diff --git a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx index 381f2ef20f0..83858b32941 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/OrgProjectSwitcher.tsx @@ -79,7 +79,7 @@ export function useConfigureOrganizationCommand() { }, ], }, - { deps: [organizations], enabled: !!organizations && organizations.length > 0 } + { deps: [organizations], enabled: IS_PLATFORM && !!organizations && organizations.length > 0 } ) useRegisterCommands( @@ -93,6 +93,6 @@ export function useConfigureOrganizationCommand() { icon: () => , }, ], - { enabled: !!organizations && organizations.length > 0 } + { enabled: IS_PLATFORM && !!organizations && organizations.length > 0 } ) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx index e711dce37d8..89e13a7ef64 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx @@ -141,14 +141,14 @@ export function PolicySearchResults({ query }: PolicySearchResultsProps) { getRoute={(result) => { const policy = policies?.find((p) => String(p.id) === result.id) if (!policy || !projectRef) - return `/project/${projectRef}/auth/policies` as `/${string}` + return `/project/${projectRef}/database/policies` as `/${string}` const params = new URLSearchParams() params.set('edit', String(policy.id)) if (policy.schema) { params.set('schema', policy.schema) } - return `/project/${projectRef}/auth/policies?${params.toString()}` as `/${string}` + return `/project/${projectRef}/database/policies?${params.toString()}` as `/${string}` }} className="pb-9" /> diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 7a578f453d1..ebae4de5b4c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -13,6 +13,7 @@ import { } from 'react' import { useFeaturePreviews } from './useFeaturePreviews' +import { IS_PLATFORM } from '@/lib/constants' import { EMPTY_OBJ } from '@/lib/void' type FeaturePreviewContextType = { @@ -43,6 +44,11 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = const initializeFlags = useEffectEvent(() => { setFlags( featurePreviews.reduce((a, b) => { + // Platform-only previews can never be enabled outside the hosted platform + if (!IS_PLATFORM && b.isPlatformOnly) { + return { ...a, [b.key]: false } + } + const defaultOptIn = b.isDefaultOptIn const localStorageValue = safeLocalStorage.getItem(b.key) return { @@ -101,8 +107,7 @@ export const useUnifiedLogsPreview = () => { export const useIsPgDeltaDiffEnabled = () => { const { flags } = useFeaturePreviewContext() - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') - return pgDeltaDiffEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] } export const useIsAdvisorRulesEnabled = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx index 4f8a00c3def..01ea7cffdc5 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/RLSTesterPreview.tsx @@ -27,8 +27,8 @@ export const RLSTesterPreview = () => {
  • Show the "Test" button on the{' '} - - Authentication Policies page + + Database Policies page
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 137ae7c8df2..1cdb5b0c3b0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -20,7 +20,6 @@ export type FeaturePreview = { } export const useFeaturePreviews = (): FeaturePreview[] => { - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') const platformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') @@ -38,7 +37,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: true, isPlatformOnly: false, isDefaultOptIn: false, - getRoute: (ref?: string) => `/project/${ref}/auth/policies`, + getRoute: (ref?: string) => `/project/${ref}/database/policies`, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, @@ -68,7 +67,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: false, isPlatformOnly: true, isDefaultOptIn: true, - enabled: pgDeltaDiffEnabled, + enabled: true, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, @@ -111,12 +110,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => { getRoute: (ref?: string) => `/project/${ref}/integrations`, }, ].sort((a, b) => Number(b.isNew) - Number(a.isNew)), - [ - unifiedLogsDefaultOptIn, - pgDeltaDiffEnabled, - platformWebhooksEnabled, - jitDbAccessEnabled, - isMarketplaceEnabled, - ] + [unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled] ) } diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index 4e0c9382d2f..8c652163256 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -7,7 +7,7 @@ import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from '@/lib/constants' diff --git a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx index e7033ebe929..7715bf6ca47 100644 --- a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx @@ -5,7 +5,7 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -14,6 +14,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { AlertError } from '@/components/ui/AlertError' diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index 27458458bc6..d85bf61150f 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -42,5 +42,22 @@ export function urlRegex( ) } +export function normalizeRedirectUrl(url: string): string { + return url.trim().replace(/\s*,\s*$/, '') +} + +export function parseRedirectUrls(allowList?: string | null): string[] { + if (!allowList) return [] + + return Array.from( + new Set( + allowList + .split(',') + .map(normalizeRedirectUrl) + .filter((url) => url.length > 0) + ) + ) +} + // Use a const string to represent no chars option. Represented as empty string on the backend side. export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index f8329d926bd..673a94855ad 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -18,7 +18,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx index f8ee6ecb6f7..9d801843c98 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/index.tsx @@ -14,7 +14,7 @@ import { import { getPhoneProviderValidationSchema, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' import type { Provider } from './AuthProvidersForm.types' import { ProviderForm } from './ProviderForm' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ResourceList } from '@/components/ui/Resource/ResourceList' import { HorizontalShimmerWithIcon } from '@/components/ui/Shimmers' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx index 982d361637d..f470b23d02c 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm.tsx @@ -32,9 +32,9 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { NO_REQUIRED_CHARACTERS } from './Auth.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -269,7 +269,7 @@ export const BasicAuthSettingsForm = () => { authenticated roles. We strongly advise{' '} reviewing your RLS policies diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx index fdf156cf7c3..a2a71318107 100644 --- a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx @@ -38,7 +38,7 @@ import { } from './customProviders.utils' import { DeleteCustomProviderModal } from './DeleteCustomProviderModal' import { DisableCustomProviderModal } from './DisableCustomProviderModal' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { FilterPopover } from '@/components/ui/FilterPopover' import { Shortcut } from '@/components/ui/Shortcut' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx index cccd998524b..f5fa434afa3 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx @@ -256,7 +256,7 @@ const PASSWORD_CHANGED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Data], + variables: [TemplateVariables.Email, TemplateVariables.Data, TemplateVariables.SiteURL], validationSchema: z.object({ MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -274,7 +274,12 @@ const EMAIL_CHANGED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.OldEmail, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.OldEmail, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -297,6 +302,7 @@ const PHONE_CHANGED_NOTIFICATION: AuthTemplate = { TemplateVariables.Phone, TemplateVariables.OldPhone, TemplateVariables.Data, + TemplateVariables.SiteURL, ], validationSchema: z.object({ MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'), @@ -315,7 +321,12 @@ const IDENTITY_LINKED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.Provider, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -333,7 +344,12 @@ const IDENTITY_UNLINKED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.Provider, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -351,7 +367,12 @@ const MFA_FACTOR_ENROLLED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.FactorType, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), @@ -369,7 +390,12 @@ const MFA_FACTOR_UNENROLLED_NOTIFICATION: AuthTemplate = { MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' }, MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' }, }, - variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data], + variables: [ + TemplateVariables.Email, + TemplateVariables.FactorType, + TemplateVariables.Data, + TemplateVariables.SiteURL, + ], validationSchema: z.object({ MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'), }), diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index e8f79f55cab..5f503ec80ad 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -26,7 +26,8 @@ import { isCustomEmailTemplateRestrictionStatusKnown, slugifyTitle, } from './EmailTemplates.utils' -import AlertError from '@/components/ui/AlertError' +import { SendEmailHookActiveAdmonition } from './SendEmailHookActiveAdmonition' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' @@ -79,6 +80,9 @@ export const EmailTemplates = () => { }) const usingBuiltInEmailSender = !hasCustomEmailSender(authConfig) + const hasSendEmailHook = !!( + authConfig?.HOOK_SEND_EMAIL_ENABLED && authConfig?.HOOK_SEND_EMAIL_URI + ) const isTemplateRestrictionStatusKnown = isCustomEmailTemplateRestrictionStatusKnown({ authConfig, organization: selectedOrganization, @@ -135,10 +139,24 @@ export const EmailTemplates = () => { )} {isSuccess && ( <> + {isTemplateEditBlocked && !hasSendEmailHook && ( + + + + + + )} + + {hasSendEmailHook && ( + + + + + + )} + - {isTemplateEditBlocked ? ( - - ) : usingBuiltInEmailSender ? ( + {usingBuiltInEmailSender && !isTemplateEditBlocked && ( { } /> - ) : null} + )} Authentication diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx index 6c54b1dd732..233e993f042 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, + AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -14,6 +15,7 @@ import { AlertDialogTrigger, Button, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { type AuthTemplate } from './EmailTemplates.types' import { getAuthTemplateType } from './EmailTemplates.utils' @@ -32,6 +34,7 @@ export const ResetTemplateDialog = ({ }) => { const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) + const [error, setError] = useState(null) const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' @@ -40,25 +43,40 @@ export const ResetTemplateDialog = ({ const { id } = template const templateType = getAuthTemplateType(id) - const { mutate: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation() + const { mutateAsync: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation({ + onSuccess: (config) => { + toast.success('Email template reset to default') + onResetSuccess(config) + }, + onError: () => {}, + }) const resetTemplateToDefault = async () => { if (!projectRef) throw new Error('Project ref is required') if (!templateType) throw new Error('Template type is required') - resetAuthTemplate( - { projectRef, template: templateType }, - { - onSuccess: (config) => { - toast.success('Email template reset to default') - onResetSuccess(config) - }, - } - ) + setError(null) + + try { + await resetAuthTemplate({ projectRef, template: templateType }) + } catch (error) { + setError( + error instanceof Error + ? error.message + : 'An unknown error occurred while resetting the template' + ) + throw error + } } return ( - + { + setOpen(next) + if (!next) setError(null) + }} + > + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx index 8b1f397f4f2..0538087f099 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import { CardContent, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Markdown } from '@/components/interfaces/Markdown' import { ValidateSpamResponse } from '@/data/auth/validate-spam-mutation' diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index dac6766a961..eff4d0e4d77 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -124,7 +124,10 @@ const renderTemplateEditor = ({ }) useAsyncCheckPermissionsMock.mockReturnValue({ can: canUpdateConfig }) useAuthConfigUpdateMutationMock.mockReturnValue({ mutate: updateAuthConfigMock }) - useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock }) + useAuthTemplateResetMutationMock.mockImplementation((options = {}) => ({ + mutateAsync: (vars: unknown) => resetTemplateMock(vars, options), + isPending: false, + })) return render() } @@ -253,4 +256,22 @@ describe('TemplateEditor reset to default', () => { expect(screen.getByRole('button', { name: 'Reset template' })).toBeDisabled() }) + + it('keeps the dialog open and shows an inline error when reset fails', async () => { + const user = userEvent.setup() + resetTemplateMock.mockImplementation(async () => { + throw new Error('Reset endpoint unavailable') + }) + + renderTemplateEditor({ hasCustomBody: true }) + + await user.click(screen.getByRole('button', { name: 'Reset template' })) + const dialog = await screen.findByRole('alertdialog') + await user.click(within(dialog).getByRole('button', { name: 'Reset' })) + + expect(await within(dialog).findByText('Reset endpoint unavailable')).toBeInTheDocument() + expect(within(dialog).getByText('Unable to reset email template')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Reset' })).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + }) }) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 49b2b65da68..dadfc4561de 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -19,7 +19,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index c1dbe06022e..8b2561a7c65 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -27,6 +27,7 @@ import { SheetTitle, Switch, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import * as z from 'zod' @@ -38,6 +39,7 @@ import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialo import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { DocsButton } from '@/components/ui/DocsButton' import FunctionSelector from '@/components/ui/FunctionSelector' +import { InlineLink } from '@/components/ui/InlineLink' import { SchemaSelector } from '@/components/ui/SchemaSelector' import { AuthConfigResponse } from '@/data/auth/auth-config-query' import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation' @@ -165,7 +167,7 @@ export const CreateHookSheet = ({ }) const isDirty = form.formState.isDirty - const values = form.watch() + const { postgresValues, hookType, selectedType, enabled } = form.watch() const { confirmOnClose, handleOpenChange, @@ -181,7 +183,7 @@ export const CreateHookSheet = ({ if ( hook.method.schema !== '' && hook.method.functionName !== '' && - hook.method.functionName !== values.postgresValues.functionName + hook.method.functionName !== postgresValues.functionName ) { permissionChanges = getRevokePermissionStatements( hook.method.schema, @@ -190,9 +192,9 @@ export const CreateHookSheet = ({ } } - if (values.postgresValues.functionName !== '') { - const schema = values.postgresValues.schema - const functionName = values.postgresValues.functionName + if (postgresValues.functionName !== '') { + const schema = postgresValues.schema + const functionName = postgresValues.functionName permissionChanges = [ ...permissionChanges, safeSql`-- Grant access to function to supabase_auth_admin @@ -204,11 +206,11 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti ] } return permissionChanges - }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) + }, [hook, postgresValues.schema, postgresValues.functionName]) const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ onSuccess: () => { - toast.success(`Successfully created ${values.hookType}.`) + toast.success(`Successfully ${isCreating ? 'created' : 'updated'} ${hookType}.`) if (statements.length > 0) { executeSql({ projectRef, @@ -306,7 +308,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti - +
( )} /> + + {hook.id === 'send-email' && enabled && ( +
+ +

+ While enabled, email data is sent to this hook instead. Your configured{' '} + + email templates + {' '} + will not be used.{' '} + + Learn more + + . +

+
+
+ )} + + ( - + )} /> - {values.selectedType === 'postgres' ? ( + {selectedType === 'postgres' ? ( <> -
- ( - - - field.onChange(name)} - disabled={field.disabled} - /> - - - )} - /> - ( - - - { - if (supportedReturnTypes.includes(func.return_type)) { - const { value } = convertArgumentTypes({ - type: func.type, - value: func.argument_types, - }) - if (value.length !== 1) return false - return value[0].type === 'json' || value[0].type === 'jsonb' - } - return false - }} - noResultsLabel={ - - No function with a single JSON/B argument -
- and JSON/B - {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' - ? ' or void' - : ''}{' '} - return type found in this schema. -
+ ( + + + field.onChange(name)} + disabled={field.disabled} + /> + + + )} + /> + ( + + + { + if (supportedReturnTypes.includes(func.return_type)) { + const { value } = convertArgumentTypes({ + type: func.type, + value: func.argument_types, + }) + if (value.length !== 1) return false + return value[0].type === 'json' || value[0].type === 'jsonb' } - /> - - - )} - /> -
+ return false + }} + noResultsLabel={ + + No function with a single JSON/B argument +
+ and JSON/B + {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ' or void' + : ''}{' '} + return type found in this schema. +
+ } + /> +
+
+ )} + /> {statements.length > 0 && ( -
-

- The following statements will be executed on the selected function: -

- -
+ <> + +
+

+ The following statements will be executed on the selected function: +

+ +
+ )} ) : ( @@ -461,11 +488,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.url" render={({ field }) => ( - + )} @@ -476,11 +504,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.secret" render={({ field }) => (

- Should be a base64 encoded hook secret with a prefix{' '} + Should be a base64 encoded secret with a prefix{' '} v1,whsec_.

@@ -496,8 +525,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
) diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index a40b77793d1..b07cc3dd605 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -1,11 +1,8 @@ -import { joinSqlFragments } from '@supabase/pg-meta/src/pg-format' import { useParams } from 'common' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { cn } from 'ui' -import { EmptyStatePresentational, GenericSkeletonLoader } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { PageSection, PageSectionAside, @@ -14,24 +11,22 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { AddHookDropdown } from './AddHookDropdown' import { CreateHookSheet } from './CreateHookSheet' +import { DeleteHookConfirmationDialog } from './DeleteHookConfirmationDialog' import { HookCard } from './HookCard' import { Hook, HOOKS_DEFINITIONS } from './hooks.constants' -import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' +import { extractMethod, isValidHook } from './hooks.utils' import { AlertError } from '@/components/ui/AlertError' -import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' -import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation' -import { executeSql } from '@/data/sql/execute-sql-mutation' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' export const HooksListing = () => { const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() + const { data: authConfig, error: authConfigError, @@ -41,32 +36,10 @@ export const HooksListing = () => { const [hook, setHook] = useQueryState('hook', parseAsString) - const [selectedHookForDeletion, setSelectedHookForDeletion] = useState(null) + const [selectedHookForDeletion, setSelectedHookForDeletion] = useState() const [addHookAsideOpen, setAddHookAsideOpen] = useState(false) const [addHookEmptyOpen, setAddHookEmptyOpen] = useState(false) - const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({ - onSuccess: async () => { - if (!selectedHookForDeletion) return - - const { method } = selectedHookForDeletion - if (method.type === 'postgres') { - const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) - await executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: joinSqlFragments(revokeStatements, '\n'), - }) - } - toast.success(`${selectedHookForDeletion.title} has been deleted.`) - setSelectedHookForDeletion(null) - setHook(null) - }, - onError: (error) => { - toast.error(`Failed to delete hook: ${error.message}`) - }, - }) - const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { ...definition, @@ -158,7 +131,14 @@ export const HooksListing = () => {
{validHooks.map((hook) => { - return setHook(hook.id)} /> + return ( + setHook(hook.id)} + onSelectDelete={() => setSelectedHookForDeletion(hook)} + /> + ) })}
@@ -173,54 +153,16 @@ export const HooksListing = () => { authConfig={authConfig!} /> - setSelectedHookForDeletion(null)} - onConfirm={() => { - if (!selectedHookForDeletion) return - updateAuthHooks({ - projectRef: projectRef!, - config: { - [selectedHookForDeletion.enabledKey]: false, - [selectedHookForDeletion.uriKey]: null, - [selectedHookForDeletion.secretsKey]: null, - }, - }) + { + if (!open) setSelectedHookForDeletion(undefined) }} - > -
-

- Are you sure you want to delete the {selectedHookForDeletion?.title}? -

- {selectedHookForDeletion?.method.type === 'postgres' && ( - <> -

- The following statements will be executed on the{' '} - {selectedHookForDeletion?.method.schema}. - {selectedHookForDeletion?.method.functionName} function: -

-
- -
- - )} -
-
+ onDeleteSuccess={() => { + setSelectedHookForDeletion(undefined) + setHook(null) + }} + /> ) diff --git a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx index 372554d3a6d..1bcc5c588aa 100644 --- a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx @@ -27,7 +27,6 @@ import { Switch, WarningIcon, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -37,11 +36,12 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx index 1a2a128334c..5a85a8a0183 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthAppsList.tsx @@ -37,7 +37,7 @@ import { OAUTH_APP_CLIENT_TYPE_OPTIONS, OAUTH_APP_REGISTRATION_TYPE_OPTIONS, } from './oauthApps.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' import { Shortcut } from '@/components/ui/Shortcut' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx index c148a09e5e2..8b4c35a69f5 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthEndpointsTable.tsx @@ -1,5 +1,7 @@ import { useParams } from 'common' import { Card, CardContent, cn } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent, @@ -7,9 +9,7 @@ import { PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { Input } from 'ui-patterns/DataInputs/Input' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { useOpenIDConfigurationQuery } from '@/data/oauth-server-apps/oauth-openid-configuration-query' diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx index 37d08c9199c..9c77405434d 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx @@ -25,7 +25,7 @@ import * as z from 'zod' import { OAuthEndpointsTable } from './OAuthEndpointsTable' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query' diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx index 2873f7bf9c7..9e21fa65f1d 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx @@ -5,7 +5,6 @@ import { BarChart2, ChevronRight, ExternalLink, Telescope } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { AiIconAnimation, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { StatusCode } from 'ui-patterns' import { Chart, ChartActions, @@ -24,6 +23,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { StatusCode } from 'ui-patterns/StatusCode' import { AuthErrorCodeRow, @@ -40,7 +40,7 @@ import { getMetricValues, } from './OverviewUsage.constants' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ErrorCodeTooltip } from '@/components/ui/ErrorCodeTooltip/ErrorCodeTooltip' import { Service } from '@/data/graphql/graphql' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' diff --git a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx index 2a3cb1234a5..dc6976ef429 100644 --- a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx @@ -21,7 +21,7 @@ import * as z from 'zod' import { validateRpId, validateWebAuthnOrigins } from './PasskeysSettingsForm.utils' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import type { components } from '@/data/api' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx b/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx index 8041604cc19..1b08af1dce1 100644 --- a/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx @@ -21,13 +21,13 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { ScaffoldSection, ScaffoldSectionTitle } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts deleted file mode 100644 index e785b11c2b9..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { safeSql } from '@supabase/pg-meta' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - generateAiPoliciesForTable, - generateProgrammaticPoliciesForTable, - generateStartingPoliciesForTable, - type GeneratedPolicy, -} from './Policies.utils' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -// Mock generateSqlPolicy for AI tests -const mockGenerateSqlPolicy = vi.fn() -vi.mock('@/data/ai/sql-policy-mutation', () => ({ - generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args), -})) - -// Helper to create a foreign key constraint -const createForeignKey = (overrides: Partial = {}): ForeignKeyConstraint => ({ - id: 1, - constraint_name: 'fk_constraint', - source_id: 100, - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_id: 200, - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - deletion_action: 'NO ACTION', - update_action: 'NO ACTION', - ...overrides, -}) - -describe('Policies.utils - Policy Generation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('generateProgrammaticPoliciesForTable', () => { - it('should generate 4 CRUD policies for direct FK to auth.users', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - const commands = policies.map((p) => p.command) - expect(commands).toContain('SELECT') - expect(commands).toContain('INSERT') - expect(commands).toContain('UPDATE') - expect(commands).toContain('DELETE') - }) - - it('should return empty array when no FK path to auth.users exists', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['category_id'], - target_schema: 'public', - target_table: 'categories', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(0) - }) - - it('should return empty array when foreignKeyConstraints is empty', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - }) - - expect(policies).toHaveLength(0) - }) - - it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => { - // posts -> profiles -> auth.users - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - id: 1, - source_schema: 'public', - source_table: 'posts', - source_columns: ['profile_id'], - target_schema: 'public', - target_table: 'profiles', - target_columns: ['id'], - }), - createForeignKey({ - id: 2, - source_schema: 'public', - source_table: 'profiles', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - // Check that the expression contains EXISTS for indirect path - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toContain('exists') - expect(selectPolicy?.sql).toContain('exists') - }) - - describe('policy structure validation', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - it('should include all required fields in generated policies', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - for (const policy of policies) { - expect(policy).toHaveProperty('name') - expect(policy).toHaveProperty('sql') - expect(policy).toHaveProperty('command') - expect(policy).toHaveProperty('table', 'posts') - expect(policy).toHaveProperty('schema', 'public') - expect(policy).toHaveProperty('action', 'PERMISSIVE') - expect(policy).toHaveProperty('roles') - expect(policy.roles).toContain('authenticated') - } - }) - - it('SELECT policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toBeDefined() - expect(selectPolicy?.check).toBeUndefined() - }) - - it('DELETE policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const deletePolicy = policies.find((p) => p.command === 'DELETE') - expect(deletePolicy?.definition).toBeDefined() - expect(deletePolicy?.check).toBeUndefined() - }) - - it('INSERT policy should have check but no definition', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const insertPolicy = policies.find((p) => p.command === 'INSERT') - expect(insertPolicy?.definition).toBeUndefined() - expect(insertPolicy?.check).toBeDefined() - }) - - it('UPDATE policy should have both definition and check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const updatePolicy = policies.find((p) => p.command === 'UPDATE') - expect(updatePolicy?.definition).toBeDefined() - expect(updatePolicy?.check).toBeDefined() - }) - - it('should generate correct SQL syntax for direct FK', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.sql).toContain('CREATE POLICY') - expect(selectPolicy?.sql).toContain('public.posts') - expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT') - expect(selectPolicy?.sql).toContain('TO authenticated') - expect(selectPolicy?.sql).toContain('USING') - expect(selectPolicy?.sql).toContain('auth.uid()') - }) - }) - - it('should handle non-public schema', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'private', - source_table: 'documents', - source_columns: ['owner_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'documents', schema: 'private' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - expect(policies[0].schema).toBe('private') - expect(policies[0].sql).toContain('private.documents') - }) - }) - - describe('generateAiPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_select_policy', - sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should return policies from AI when called with valid inputs', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }, { name: 'title' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({ - tableName: 'posts', - schema: 'public', - columns: ['id', 'title'], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when connectionString is null', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: null, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when connectionString is undefined', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: undefined, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should handle API errors gracefully and return empty array', async () => { - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - mockGenerateSqlPolicy.mockRejectedValue(new Error('API error')) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(policies).toEqual([]) - expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error)) - - consoleLogSpy.mockRestore() - }) - - it('should trim column names before sending to API', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: ' id ' }, { name: ' title ' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith( - expect.objectContaining({ - columns: ['id', 'title'], - }) - ) - }) - }) - - describe('generateStartingPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_policy', - sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should use programmatic policies when FK path exists (does not call AI)', async () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - - it('should fall back to AI when no FK path exists and enableAi is true', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalled() - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when no FK path exists and enableAi is false', async () => { - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: false, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when no FK path and AI returns empty', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toEqual([]) - }) - - it('should prioritize programmatic over AI even when both could generate policies', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - // Should return 4 programmatic policies, not 1 AI policy - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts deleted file mode 100644 index d98c9322558..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { - acceptUntrustedSql, - ident, - safeSql, - untrustedSql, - type DisplayableSqlFragment, - type SafeSqlFragment, -} from '@supabase/pg-meta' -import type { PGPolicy } from '@supabase/pg-meta' -import { has, isEmpty, isEqual } from 'lodash' - -import { - DraftPostgresPolicyCreatePayload, - DraftPostgresPolicyUpdatePayload, - PolicyFormField, - PolicyForReview, -} from './Policies.types' -import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation' -import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -/** - * Returns an array of SQL statements that will preview in the review step of the policy editor - * @param {*} policyFormFields { name, using, check, command } - */ - -export const createSQLPolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields?: PGPolicy -) => { - const { definition, check } = policyFormFields - const formattedPolicyFormFields = { - ...policyFormFields, - definition: definition - ? definition.replace(/\s+/g, ' ').trim() - : definition === undefined - ? null - : definition, - check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check, - } - - if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) { - return createSQLStatementForCreatePolicy(formattedPolicyFormFields) - } - - // If there are no changes, return an empty object - if (isEqual(policyFormFields, originalPolicyFormFields)) { - return {} - } - - // Extract out all the fields that updated - const fieldsToUpdate: any = {} - if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) { - fieldsToUpdate.name = formattedPolicyFormFields.name - } - if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) { - fieldsToUpdate.definition = formattedPolicyFormFields.definition - } - if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) { - fieldsToUpdate.check = formattedPolicyFormFields.check - } - if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) { - fieldsToUpdate.roles = formattedPolicyFormFields.roles - } - - if (!isEmpty(fieldsToUpdate)) { - return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate) - } - - return {} -} - -const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => { - const { name, definition, check, command, schema, table } = policyFormFields - const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles - const description = `Add policy for the ${command} operation under the policy "${name}"` - const statement = [ - `CREATE POLICY "${name}" ON "${schema}"."${table}"`, - `AS PERMISSIVE FOR ${command}`, - `TO ${roles.join(', ')}`, - `${definition ? `USING (${definition})` : ''}`, - `${check ? `WITH CHECK (${check})` : ''}`, - ].join('\n') - - return { description, statement } -} - -const createSQLStatementForUpdatePolicy = ( - policyFormFields: PolicyFormField, - fieldsToUpdate: Partial -): PolicyForReview => { - const { name, schema, table } = policyFormFields - - const definitionChanged = has(fieldsToUpdate, ['definition']) - const checkChanged = has(fieldsToUpdate, ['check']) - const nameChanged = has(fieldsToUpdate, ['name']) - const rolesChanged = has(fieldsToUpdate, ['roles']) - - const parameters = Object.keys(fieldsToUpdate) - const description = `Update policy's ${ - parameters.length === 1 - ? parameters[0] - : `${parameters.slice(0, parameters.length - 1).join(', ')} and ${ - parameters[parameters.length - 1] - }` - } ` - const roles = - (fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[]) - - const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"` - const statement = [ - 'BEGIN;', - ...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []), - ...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []), - ...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []), - ...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []), - 'COMMIT;', - ].join('\n') - - return { description, statement } -} - -// These constructors return DRAFT payloads — `definition`/`check` are still -// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture -// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that -// it was reached via a deliberate user action. -export const createPayloadForCreatePolicy = ( - policyFormFields: PolicyFormField -): DraftPostgresPolicyCreatePayload => { - const { name, schema, table, command, definition, check, roles } = policyFormFields - return { - name, - schema, - table, - action: 'PERMISSIVE', - command: command || undefined, - definition: !definition ? undefined : untrustedSql(definition), - check: !check ? undefined : untrustedSql(check), - roles: roles.length > 0 ? roles : undefined, - } -} - -export const createPayloadForUpdatePolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields: PGPolicy -): DraftPostgresPolicyUpdatePayload => { - const { definition, check } = policyFormFields - const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition - const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check - - const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id } - - if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) { - payload.name = policyFormFields.name - } - if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) { - payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition) - } - if (!isEqual(formattedCheck, originalPolicyFormFields.check)) { - payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck) - } - if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) { - if (policyFormFields.roles.length === 0) payload.roles = ['public'] - else payload.roles = policyFormFields.roles || undefined - } - - return payload -} - -// --- Policy Generation --- - -/** - * A policy generated for display/staging in the table editor. - * `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance: - * programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation - * produces `UntrustedSqlFragment` (third-party output). Consumers must promote via - * `acceptUntrustedSql` at a user gesture before executing. - */ -export type GeneratedPolicy = Required< - Pick -> & - Pick & { - definition?: DisplayableSqlFragment - check?: DisplayableSqlFragment - sql: string - } - -/** - * A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to - * `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture - * required to execute this SQL has already happened. - */ -export type AcceptedGeneratedPolicy = Omit & { - definition?: SafeSqlFragment - check?: SafeSqlFragment -} - -/** - * Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}. - * ONLY call from an event handler tied to a deliberate user action (e.g. the Save click - * on the table editor). Never call from useEffect, render, or any path that runs without - * a user gesture. - */ -export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({ - ...policy, - definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition), - check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check), -}) - -type Relationship = { - source_schema: string - source_table_name: string - source_column_name: string - target_table_schema: string - target_table_name: string - target_column_name: string -} - -/** - * Gets relationships for a specific table from FK constraints. - * Returns relationships where the table is the source. - */ -const getRelationshipsForTable = ({ - schema, - table, - fkConstraints, -}: { - schema: string - table: string - fkConstraints: ForeignKeyConstraint[] -}): Relationship[] => { - return fkConstraints - .filter((fk) => fk.source_schema === schema && fk.source_table === table) - .flatMap((fk) => - fk.source_columns.map((sourceCol, i) => ({ - source_schema: fk.source_schema, - source_table_name: fk.source_table, - source_column_name: sourceCol, - target_table_schema: fk.target_schema, - target_table_name: fk.target_table, - target_column_name: fk.target_columns[i], - })) - ) -} - -/** - * BFS to find shortest path from table to auth.users via foreign key relationships. - * Returns null if no path exists within maxDepth. - */ -const findPathToAuthUsers = ( - startTable: { schema: string; name: string }, - allForeignKeyConstraints: ForeignKeyConstraint[], - maxDepth = 3 -): Relationship[] | null => { - const startRelationships = getRelationshipsForTable({ - schema: startTable.schema, - table: startTable.name, - fkConstraints: allForeignKeyConstraints, - }) - - const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [ - { table: startTable, path: [] }, - ] - const visited = new Set() - visited.add(`${startTable.schema}.${startTable.name}`) - - while (queue.length > 0) { - const queueItem = queue.shift() - if (!queueItem) continue - - const { table, path } = queueItem - if (path.length >= maxDepth) continue - - const relationships = - path.length === 0 - ? startRelationships - : getRelationshipsForTable({ - schema: table.schema, - table: table.name, - fkConstraints: allForeignKeyConstraints, - }) - - for (const rel of relationships) { - // Found path to auth.users - if ( - rel.target_table_schema === 'auth' && - rel.target_table_name === 'users' && - rel.target_column_name === 'id' - ) { - return [...path, rel] - } - - const targetId = `${rel.target_table_schema}.${rel.target_table_name}` - if (visited.has(targetId)) continue - - // Add target table to queue for further exploration - queue.push({ - table: { schema: rel.target_table_schema, name: rel.target_table_name }, - path: [...path, rel], - }) - visited.add(targetId) - } - } - - return null -} - -/** Generates SQL expression for RLS policy based on FK path to auth.users */ -const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => { - if (path.length === 0) return safeSql`` - - // Direct FK to auth.users - if (path.length === 1) { - return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}` - } - - // Indirect path - build EXISTS with JOINs - const [first, ...rest] = path - const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}` - const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}` - const last = path[path.length - 1] - - const joins = rest.slice(0, -1).reduce( - (acc, r) => { - const targetSchema = ident(r.target_table_schema) - const targetTable = ident(r.target_table_name) - const targetColumn = ident(r.target_column_name) - - const sourceSchema = ident(r.source_schema) - const sourceTable = ident(r.source_table_name) - const sourceColumn = ident(r.source_column_name) - const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}` - return acc.length === 0 ? join : safeSql`${acc}\n ${join}` - }, - safeSql`` - ) - - return safeSql`exists ( - select 1 from ${firstTarget} - ${joins} - where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)} - and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid()) -)` -} - -/** Builds policy SQL for all CRUD operations */ -const buildPoliciesForPath = ( - table: { name: string; schema: string }, - path: Relationship[] -): GeneratedPolicy[] => { - const expression = buildPolicyExpression(path) - const targetCol = path[0].source_column_name - - return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => { - const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}` - const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated` - - const sql = - command === 'INSERT' - ? `${base} WITH CHECK (${expression});` - : command === 'UPDATE' - ? `${base} USING (${expression}) WITH CHECK (${expression});` - : `${base} USING (${expression});` - - // Structured data for mutation API - const definition = command === 'INSERT' ? undefined : expression - const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression - - return { - name, - sql, - command, - table: table.name, - schema: table.schema, - definition, - check, - action: 'PERMISSIVE' as const, - roles: ['authenticated'], - } - }) -} - -/** - * Generates RLS policies programmatically based on FK relationships to auth.users. - */ -export const generateProgrammaticPoliciesForTable = ({ - table, - foreignKeyConstraints, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] -}): GeneratedPolicy[] => { - try { - const path = findPathToAuthUsers(table, foreignKeyConstraints) - - if (path?.length) { - return buildPoliciesForPath(table, path) - } - } catch (error) { - // Silently fail - caller will handle empty result - } - - return [] -} - -/** - * Generates RLS policies using AI. - */ -export const generateAiPoliciesForTable = async ({ - table, - columns, - projectRef, - connectionString, -}: { - table: { name: string; schema: string } - columns: { name: string }[] - projectRef: string - connectionString?: string | null -}): Promise => { - if (!connectionString) return [] - - try { - return await generateSqlPolicy({ - tableName: table.name, - schema: table.schema, - columns: columns.map((col) => col.name.trim()), - projectRef, - connectionString: connectionString ?? '', - }) - } catch (error) { - console.log('AI policy generation failed:', error) - return [] - } -} - -/** - * Generates RLS policies for a table. - * First tries programmatic generation based on FK relationships to auth.users. - * Falls back to AI generation if no path exists. - */ -export const generateStartingPoliciesForTable = async ({ - table, - foreignKeyConstraints, - columns, - projectRef, - connectionString, - enableAi, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] - columns: { name: string }[] - projectRef: string - connectionString?: string | null - enableAi: boolean -}): Promise => { - // Try programmatic generation first - const programmaticPolicies = generateProgrammaticPoliciesForTable({ - table, - foreignKeyConstraints, - }) - - if (programmaticPolicies.length > 0) { - return programmaticPolicies - } - - // Fall back to AI generation - if (enableAi) { - return await generateAiPoliciesForTable({ - table, - columns, - projectRef, - connectionString, - }) - } - - return [] -} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts deleted file mode 100644 index 19fe1d66272..00000000000 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTemplates/PolicyTemplates.constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' - -export interface PolicyTemplate { - id: string - preview: boolean - templateName: string - description: string - name: string - statement: string - definition: SafeSqlFragment - check: SafeSqlFragment - command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL' - roles: Array -} diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index 1b4876e86e8..a626f8a0de8 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -21,7 +21,6 @@ import { SelectValue, Switch, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -31,12 +30,13 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx deleted file mode 100644 index d56074cc27c..00000000000 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { - acceptUntrustedSql, - safeSql, - type SafeSqlFragment, - type UntrustedSqlFragment, -} from '@supabase/pg-meta' -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@ui/components/shadcn/ui/select' -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' -import { Code, ExternalLink } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import { - Button, - DialogSectionSeparator, - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetSection, - SheetTitle, - SheetTrigger, -} from 'ui' -import { Admonition } from 'ui-patterns' - -import { InferredSQLViewer } from './InferredSQLViewer' -import { type ParseQueryResults } from './RLSTester.types' -import { RLSTesterEmptyState } from './RLSTesterEmptyState' -import { RLSTesterResults } from './RLSTesterResults' -import { RoleSelector } from './RoleSelector' -import { SandboxManagement } from './SandboxManagement' -import { UserSelector } from './UserSelector' -import { UserSqlEditor } from './UserSqlEditor' -import { useTestQueryRLS } from './useTestQueryRLS' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' -import { useTrack } from '@/lib/telemetry/track' -import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' -import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox' -import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' -import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' - -interface RLSTesterSheetProps { - handleSelectEditPolicy: (policy: Policy) => void -} - -export const RLSTesterSheet = (props: RLSTesterSheetProps) => { - return ( - - - - ) -} - -const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { - const track = useTrack() - const aiSnap = useAiAssistantStateSnapshot() - const { openSidebar } = useSidebarManagerSnapshot() - const { setRole } = useRoleImpersonationStateSnapshot() - const sandboxEnabled = useFlag('rlsTesterSandbox') - - const [open, setOpen] = useState(false) - const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') - - const [format, setFormat] = useState<'sql' | 'lib'>('sql') - const [inferredSQL, setInferredSQL] = useState() - - const [value, setValue] = useState(safeSql``) - const [results, setResults] = useState(null) - const [autoLimit, setAutoLimit] = useState(false) - const [parseQueryResults, setParseQueryResults] = useState() - - const { - testQuery, - inferSQLFromLib, - isLoading, - isInferring, - executeSqlError, - parseQueryError, - parseClientCodeError, - } = useTestQueryRLS() - - const debounceRef = useRef | null>(null) - - const handleValueChange = (sql: SafeSqlFragment) => { - setValue(sql) - if (format !== 'lib') return - - if (debounceRef.current !== null) clearTimeout(debounceRef.current) - if (!sql) return - - debounceRef.current = setTimeout(() => { - inferSQLFromLib(sql, setInferredSQL) - }, 1500) - } - - const executionCallbacks = { - option: selectedOption, - onExecuteSQL: ({ result, isAutoLimit }: { result: Object[] | null; isAutoLimit: boolean }) => { - setResults(result) - setAutoLimit(isAutoLimit) - }, - onParseQuery: setParseQueryResults, - } - - const onRunQuery = async () => { - if (format === 'lib') { - if (!inferredSQL) return - await testQuery({ value: acceptUntrustedSql(inferredSQL), ...executionCallbacks }) - track('rls_tester_run_query_clicked', { type: 'inferred' }) - } else { - await testQuery({ value, ...executionCallbacks }) - track('rls_tester_run_query_clicked', { type: 'raw' }) - } - } - - const assistantSql = format === 'lib' && inferredSQL ? acceptUntrustedSql(inferredSQL) : value - - const getDebugPrompt = ({ includeSql = false }: { includeSql?: boolean } = {}) => { - const prompt = `Help me fix my RLS policy based on the attached SQL snippet that gave the following error: \n\n${executeSqlError?.message}\n\nEvaluate if the problem might be query first, before checking my RLS policies.` - - return includeSql ? `${prompt}\n\nSQL Query:\n\`\`\`sql\n${assistantSql}\n\`\`\`` : prompt - } - - const onDebugWithAssistant = () => { - const prompt = getDebugPrompt() - openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat({ - name: 'Debug RLS policies', - sqlSnippets: [assistantSql], - initialInput: prompt, - }) - setOpen(false) - } - - useEffect(() => { - if (open) { - setRole({ type: 'postgrest', role: 'anon' }) - } else { - // Flip back to service role - setRole(undefined) - } - }, [open, setRole]) - - return ( - - - - - - - - - What data can my users see? - - - - See what data a user is allowed to read based on your RLS policies - - - -
- {sandboxEnabled && } - - -
- - {selectedOption === 'authenticated' && } -
- - - -
-

Query

-
- -
-
- -
- { - if (!isInferring && !isLoading) onRunQuery() - }, - }, - }} - /> -
-
- - {format === 'lib' && ( -
- - -
- )} - - - - {parseQueryError ? ( -
- -
- ) : parseClientCodeError ? ( -
- -
- ) : ( - executeSqlError && ( -
- getDebugPrompt({ includeSql: true })} - onOpenAssistant={onDebugWithAssistant} - />, - ]} - /> -
- ) - )} - - {results === null ? ( - !parseQueryError && !parseClientCodeError && !executeSqlError && - ) : !!parseQueryResults ? ( - - ) : null} -
- - - -
- - -
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts deleted file mode 100644 index d6862e5e1cd..00000000000 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' -import type { ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation' - -export function filterTablePolicies({ - policies, - schema, - table, - role, - operation, -}: { - policies: Policy[] - schema: string - table: string - role: string | undefined - operation: ParseSQLQueryResponse['operation'] -}): Policy[] { - return policies.filter( - (x) => - x.schema === schema && - x.table === table && - (x.roles.includes(role ?? '') || (x.roles.length === 1 && x.roles[0] === 'public')) && - (x.command === 'ALL' || x.command === operation) - ) -} diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index 49fbcf5abd3..ba2dba4f676 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -35,9 +35,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -107,31 +107,31 @@ export const RateLimits = () => { RATE_LIMIT_TOKEN_REFRESH: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an 5 minutes'), + .max(2147483647, 'Must not be more than 2,147,483,647 in 5 minutes'), RATE_LIMIT_VERIFY: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an 5 minutes'), + .max(2147483647, 'Must not be more than 2,147,483,647 in 5 minutes'), RATE_LIMIT_EMAIL_SENT: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_SMS_SENT: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_ANONYMOUS_USERS: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_OTP: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), RATE_LIMIT_WEB3: z.coerce .number() .min(0, 'Must be not be lower than 0') - .max(32767, 'Must not be more than 32,767 an hour'), + .max(2147483647, 'Must not be more than 2,147,483,647 per hour'), }) const rateLimitForm = useForm>({ diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx index dc89c65f59e..43137ec556e 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx @@ -80,7 +80,7 @@ describe('AddNewURLModal', () => { expect(toast.success).toHaveBeenCalledWith('Successfully added 1 URL') }) - it('dedupes URLs after normalising a trailing comma before submitting', async () => { + it('normalizes a trailing-comma URL before submitting', async () => { const user = userEvent.setup() mutateMock.mockImplementation((_vars, callbacks) => callbacks?.onSuccess?.()) @@ -88,11 +88,7 @@ describe('AddNewURLModal', () => { await screen.findByRole('dialog') - await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com') - await user.click(screen.getByRole('button', { name: 'Add URL' })) - - const urlInputs = screen.getAllByPlaceholderText('https://mydomain.com') - await user.type(urlInputs[1], 'https://app.example.com,') + await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com,') fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) @@ -130,4 +126,24 @@ describe('AddNewURLModal', () => { expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() expect(mutateMock).not.toHaveBeenCalled() }) + + it('rejects a whitespace-padded URL when it already exists in the allow list', async () => { + const user = userEvent.setup() + + render( + + ) + + await screen.findByRole('dialog') + + await user.type( + screen.getByPlaceholderText('https://mydomain.com'), + ' https://existing.example.com ' + ) + + fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) + + expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() + expect(mutateMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx index fcd9160d02b..7e7cffa8b6c 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx @@ -22,7 +22,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' import * as z from 'zod' -import { urlRegex } from '../Auth.constants' +import { normalizeRedirectUrl, parseRedirectUrls, urlRegex } from '../Auth.constants' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' const MAX_URLS_LENGTH = 2 * 1024 @@ -33,61 +33,88 @@ interface AddNewURLModalProps { onClose: () => void } -const normaliseUrl = (value: string) => value.replace(/,\s*$/, '') +const createRedirectUrlsSchema = (normalizedAllowList: string[]) => { + const redirectUrlRegex = urlRegex() + + return z + .object({ + urls: z + .object({ + value: z.string().trim().min(1, 'Please provide a value').transform(normalizeRedirectUrl), + }) + .array() + .default([]), + }) + .superRefine((data, ctx) => { + const seenUrls = new Set() + + data.urls.forEach((url, index) => { + if (!redirectUrlRegex.test(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'Please provide a valid URL', + }) + } + + if (normalizedAllowList.includes(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in the allow list', + }) + } + + if (seenUrls.has(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in this list', + }) + } + + seenUrls.add(url.value) + }) + }) +} export const AddNewURLModal = ({ visible, allowList, onClose }: AddNewURLModalProps) => { const { ref } = useParams() const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() - const redirectUrlRegex = urlRegex() - - const FormSchema = z.object({ - urls: z - .object({ - value: z - .string() - .min(1, 'Please provide a value') - .refine( - (value) => redirectUrlRegex.test(normaliseUrl(value)), - 'Please provide a valid URL' - ) - .refine((value) => !allowList.includes(normaliseUrl(value)), { - message: 'URL already exists in the allow list', - }), - }) - .array() - .default([]), - }) + const normalizedAllowList = parseRedirectUrls(allowList.join(',')) + const formSchema = createRedirectUrlsSchema(normalizedAllowList) const initialValues = { urls: [{ value: '' }] } - const form = useForm>({ - resolver: zodResolver(FormSchema), + const form = useForm>({ + resolver: zodResolver(formSchema), defaultValues: initialValues, }) const urls = form.watch('urls') - const onSubmit = (data: z.infer) => { - const dedupedUrls = [...new Set(data.urls.map((url) => normaliseUrl(url.value)))] - const payloadUrls = allowList.concat(dedupedUrls) - const addedCount = dedupedUrls.length - const payload = payloadUrls.toString() + const onSubmit = (data: z.infer) => { + const payload = parseRedirectUrls( + normalizedAllowList.concat(data.urls.map((url) => url.value)).join(',') + ) + const payloadString = payload.join(',') + const addedCount = payload.length - normalizedAllowList.length - if (payload.length > MAX_URLS_LENGTH) { + if (payloadString.length > MAX_URLS_LENGTH) { return toast.error('Too many redirect URLs, please remove some or try to use wildcards') - } else { - updateAuthConfig( - { projectRef: ref!, config: { URI_ALLOW_LIST: payload } }, - { - onError: (error) => { - toast.error(`Failed to add URL(s): ${error?.message}`) - }, - onSuccess: () => { - toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) - form.reset(initialValues) - onClose() - }, - } - ) } + + updateAuthConfig( + { projectRef: ref!, config: { URI_ALLOW_LIST: payloadString } }, + { + onError: (error) => { + toast.error(`Failed to add URL(s): ${error?.message}`) + }, + onSuccess: () => { + toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) + form.reset(initialValues) + onClose() + }, + } + ) } useEffect(() => { diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx index 5819985f817..0441dfab252 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx @@ -23,10 +23,11 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { parseRedirectUrls } from '../Auth.constants' import { AddNewURLModal } from './AddNewURLModal' import { RedirectUrlList } from './RedirectUrlList' import { ValueContainer } from './ValueContainer' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { HorizontalShimmerWithIcon } from '@/components/ui/Shimmers' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' @@ -46,9 +47,7 @@ export const RedirectUrls = () => { useAuthConfigUpdateMutation() const URI_ALLOW_LIST_ARRAY = useMemo(() => { - return authConfig?.URI_ALLOW_LIST - ? authConfig.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url) - : [] + return parseRedirectUrls(authConfig?.URI_ALLOW_LIST) }, [authConfig?.URI_ALLOW_LIST]) const [open, setOpen] = useState(false) @@ -58,8 +57,7 @@ export const RedirectUrls = () => { const onConfirmDeleteUrl = async (urls?: string[]) => { if (!urls || urls.length === 0) return - // Remove selectedUrl from array and update - const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !selectedUrls.includes(url)) + const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !urls.includes(url)) const payloadString = payload.join(',') await updateAuthConfig( { projectRef: projectRef!, config: { URI_ALLOW_LIST: payloadString } }, diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx index dc987721b65..a57bdfd515a 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -18,7 +18,6 @@ import { InputGroupText, Switch, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -27,10 +26,11 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' -import AlertError from '@/components/ui/AlertError' -import NoPermission from '@/components/ui/NoPermission' +import { AlertError } from '@/components/ui/AlertError' +import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' diff --git a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.test.tsx b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.test.tsx new file mode 100644 index 00000000000..5d949e8dc71 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { toast } from 'sonner' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SiteUrl from './SiteUrl' +import type { AuthConfigResponse } from '@/data/auth/auth-config-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: () => ({ can: true, isSuccess: true }), +})) + +vi.mock('@/lib/constants', async () => { + const actual = await vi.importActual>('@/lib/constants') + return { + ...actual, + get IS_PLATFORM() { + return true + }, + } +}) + +const CURRENT_SITE_URL = 'https://old.example.com' + +const renderSiteUrl = () => customRender() + +describe('SiteUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: { SITE_URL: CURRENT_SITE_URL } as unknown as AuthConfigResponse, + }) + }) + + it('trims leading and trailing whitespace from the site URL before submitting', async () => { + const user = userEvent.setup() + + const requests: Array<{ body: unknown }> = [] + addAPIMock({ + method: 'patch', + path: '/platform/auth/:ref/config', + response: async ({ request }) => { + requests.push({ body: await request.json() }) + return HttpResponse.json({ + SITE_URL: 'https://new.example.com', + } as unknown as AuthConfigResponse) + }, + }) + + renderSiteUrl() + + const input = await screen.findByDisplayValue(CURRENT_SITE_URL) + + await user.clear(input) + await user.type(input, ' https://new.example.com ') + + fireEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0].body).toEqual({ SITE_URL: 'https://new.example.com' }) + expect(toast.success).toHaveBeenCalledWith('Successfully updated site URL') + }) + + it('shows a validation error and does not submit when the value is only whitespace', async () => { + const user = userEvent.setup() + + const requests: Array<{ body: unknown }> = [] + addAPIMock({ + method: 'patch', + path: '/platform/auth/:ref/config', + response: async ({ request }) => { + requests.push({ body: await request.json() }) + return HttpResponse.json({ + SITE_URL: '', + } as unknown as AuthConfigResponse) + }, + }) + + renderSiteUrl() + + const input = await screen.findByDisplayValue(CURRENT_SITE_URL) + + await user.clear(input) + await user.type(input, ' ') + + fireEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + + expect(await screen.findByText('Must have a Site URL')).toBeInTheDocument() + await waitFor(() => expect(requests).toHaveLength(0)) + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx index 84bf7fdcf7f..b4c0d5d6298 100644 --- a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx +++ b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Input } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -14,15 +13,16 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' const schema = z.object({ - SITE_URL: z.string().min(1, 'Must have a Site URL'), + SITE_URL: z.string().trim().min(1, 'Must have a Site URL'), }) const SiteUrl = () => { diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 8672aeb72c9..b2570e70367 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -19,9 +19,10 @@ import { InputGroupText, Switch, } from 'ui' -import { Admonition, PageSection, PageSectionContent } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import * as z from 'zod' import { urlRegex } from '../Auth.constants' diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx index 7dcc4640d47..4697f0f4aed 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx @@ -29,7 +29,7 @@ import { getIntegrationTypeLabel, INTEGRATION_TYPES, } from './ThirdPartyAuthForm.utils' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' import { useDeleteThirdPartyAuthIntegrationMutation } from '@/data/third-party-auth/integration-delete-mutation' diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 216cf0232c6..12614f426e6 100644 --- a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -63,7 +63,10 @@ export const SortDropdown = ({ return ( - diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index ac14ea57465..beaa3fcd4fb 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -4,8 +4,9 @@ import Link from 'next/link' import { useQueryState } from 'nuqs' import { useEffect } from 'react' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { UserHeader } from './UserHeader' import { PANEL_PADDING } from './Users.constants' diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx index be3636aaa4e..5e9723437f0 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx @@ -7,10 +7,10 @@ import { Input, ResizableHandle, ResizablePanel, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { SimpleCodeBlock } from 'ui-patterns/SimpleCodeBlock' @@ -64,7 +64,7 @@ export const UserPanel = () => { icon={} onClick={() => setSelectedId(null)} /> - setView(value as 'overview' | 'raw' | 'logs')} @@ -78,39 +78,36 @@ export const UserPanel = () => {
) : !!selectedUser ? ( <> - - + Overview - - + Logs - - + Raw JSON - - + + - + {selectedUser && ( setSelectedId(null)} /> )} - - + + {selectedUser && } - - + @@ -135,7 +132,7 @@ export const UserPanel = () => { {JSON.stringify(filteredProperties, null, 2)} - + ) : (
@@ -145,7 +142,7 @@ export const UserPanel = () => {

ID: {selectedId}

)} - + ) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 71adece92db..76624b3b31b 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -807,6 +807,7 @@ export const UsersV2 = () => { renderRow(id, props) { return ( { const user = users.find((u) => u.id === id) diff --git a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx index d13aaf25138..1686bdf5b0f 100644 --- a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx +++ b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx index 144dfe661f6..5d70043695b 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useOrganizationPaymentMethodDeleteMutation } from '@/data/organizations/organization-payment-method-delete-mutation' import type { OrganizationPaymentMethod } from '@/data/organizations/organization-payment-methods-query' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx index a8b9da11623..cd4037b16a5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx @@ -51,7 +51,7 @@ import { getURL } from '@/lib/helpers' export const BillingCustomerDataSchema = z.object({ tax_id_type: z.string(), - tax_id_value: z.string().min(2, { + tax_id_value: z.string().trim().min(2, { message: 'Tax ID needs to be set.', }), tax_id_name: z.string(), @@ -140,7 +140,8 @@ export const NewPaymentMethodElement = forwardRef( form.setValue('tax_id_name', name) } - const { tax_id_name, tax_id_value } = form.watch() + const { tax_id_name, tax_id_value: rawTaxIdValue } = form.watch() + const taxIdValue = rawTaxIdValue?.trim() ?? '' const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) @@ -149,16 +150,16 @@ export const NewPaymentMethodElement = forwardRef( >(undefined) useEffect(() => { if (!onTaxIdChange) return - if (purchasingAsBusiness && selectedTaxId && tax_id_value) { + if (purchasingAsBusiness && selectedTaxId && taxIdValue) { onTaxIdChange({ country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, - value: tax_id_value, + value: taxIdValue, }) } else { onTaxIdChange(null) } - }, [purchasingAsBusiness, selectedTaxId, tax_id_value, onTaxIdChange]) + }, [purchasingAsBusiness, selectedTaxId, taxIdValue, onTaxIdChange]) const addressCountry = stripeAddress?.address.country const availableTaxIds = useMemo(() => { @@ -212,7 +213,7 @@ export const NewPaymentMethodElement = forwardRef( ? { country: getEffectiveTaxCountry(selectedTaxId), type: selectedTaxId.type, - value: form.getValues('tax_id_value'), + value: form.getValues('tax_id_value').trim(), } : null } diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx index d6ce0676d04..85b92a6c18a 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx @@ -17,10 +17,10 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { FormPanel } from '@/components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from '@/components/ui/Forms/FormSection' -import NoPermission from '@/components/ui/NoPermission' +import { NoPermission } from '@/components/ui/NoPermission' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils' import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query' diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx index c51f5dbc1f5..76fd9d3bbdb 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' // [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth interface DiskIOBandwidthWarningsProps { diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 65a65c1c546..0b1e7890e51 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -4,8 +4,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, ReactNode } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { TimestampInfo } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { WorkflowLogs } from './WorkflowLogs' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 95cdf11692b..8bd01d65a4d 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -42,7 +42,7 @@ import { } from './BranchManagement.utils' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index 98b00272f4c..fdaa39ad42a 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -1,4 +1,3 @@ -import { basename } from 'path' import { IS_PLATFORM } from 'common' import { Circle, Code, Minus, Plus, Wind } from 'lucide-react' import Link from 'next/link' @@ -7,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' import { DiffEditor } from '@/components/ui/DiffEditor' import type { EdgeFunctionBodyData } from '@/data/edge-functions/edge-function-body-query' -import type { - EdgeFunctionsDiffResult, - FileInfo, - FileStatus, +import { + fileKey, + type EdgeFunctionsDiffResult, + type FileInfo, + type FileStatus, } from '@/hooks/branches/useEdgeFunctionsDiff' import { EMPTY_ARR } from '@/lib/void' @@ -31,9 +31,6 @@ interface FunctionDiffProps { fileInfos: FileInfo[] } -// Helper to canonicalize file identifiers to prevent mismatch due to differing root paths -const fileKey = (fullPath: string) => basename(fullPath) - // Helper to get the status color for file indicators const getStatusColor = (status: FileStatus): string => { switch (status) { diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx index 452da4ecf0f..7186cf00078 100644 --- a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -12,7 +12,7 @@ import { AlertDialogTrigger, Button, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useTrack } from '@/lib/telemetry/track' diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx index 3902ce11504..8ead7ed8cd5 100644 --- a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx +++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx @@ -14,11 +14,12 @@ import { DialogTrigger, StatusIcon, } from 'ui' -import { GenericSkeletonLoader, TimestampInfo } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge' import BranchStatusBadge from './BranchStatusBadge' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { ActionRunData } from '@/data/actions/action-detail-query' import { useActionRunLogsQuery } from '@/data/actions/action-logs-query' import { diff --git a/apps/studio/components/interfaces/Branching/MergeRequest.tsx b/apps/studio/components/interfaces/Branching/MergeRequest.tsx index 681fa079482..a1c1e2ae36b 100644 --- a/apps/studio/components/interfaces/Branching/MergeRequest.tsx +++ b/apps/studio/components/interfaces/Branching/MergeRequest.tsx @@ -12,7 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { useIsPgDeltaDiffEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { ReviewWithAI } from '../BranchManagement/ReviewWithAI' diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index b9825fa2360..717a0db9b16 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -1,12 +1,11 @@ import { useParams } from 'common' import dynamic from 'next/dynamic' import Link from 'next/link' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, type ComponentType } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { connectSchema } from './connect.schema' import type { ConnectionStringPooler, ConnectState, @@ -16,6 +15,10 @@ import type { StepContentProps, } from './Connect.types' import { ConnectSheetStep } from './ConnectSheetStep' +import { + shouldFetchDataApiConfig, + shouldShowDataApiDisabledWarning, +} from './ConnectStepsSection.utils' import { CopyPromptAdmonition } from './CopyPromptAdmonition' import { buildConnectionStringPooler, getConnectionStrings } from './DatabaseSettings.utils' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' @@ -26,6 +29,7 @@ import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-config import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -129,6 +133,27 @@ function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionSt ) } +// Vite needs `import.meta.glob` to statically discover the step content +// modules because the `${filePath}` template can span multiple directory +// segments (`flask/supabasepy`, `steps/shadcn/explore`, ...) which Vite's +// dynamic-import-vars plugin can't analyze. Skip the glob on the SSR bundle +// — Vite replaces `import.meta.env.SSR` at build time and tree-shakes the +// call so the 37 content modules stay out of the server graph (pulling them +// in reshuffles chunks enough to surface latent circular-dep bugs in +// unrelated modules). Next/webpack doesn't know about `import.meta.glob` +// either; the try/catch lets that branch fall through to the webpack-friendly +// `import()` below. +let contentModules: Record Promise> = {} +if (!import.meta.env?.SSR) { + try { + contentModules = import.meta.glob('./content/**/content.{tsx,ts}') + } catch { + // webpack build: import.meta.glob is undefined, keep empty map + } +} + +type StepContentModule = { default: ComponentType } + /** * Dynamically loads and renders a content component from the content directory. * All step content uses this unified loader - no built-in component registry needed. @@ -151,7 +176,16 @@ function StepContent({ // Dynamically import the content component const ContentComponent = useMemo(() => { - return dynamic(() => import(`./content/${filePath}/content`), { + const viteLoader = + contentModules[`./content/${filePath}/content.tsx`] ?? + contentModules[`./content/${filePath}/content.ts`] + + const loader = viteLoader + ? (viteLoader as () => Promise) + : () => + import(/* @vite-ignore */ `./content/${filePath}/content`) as Promise + + return dynamic(loader, { loading: () => (
@@ -196,11 +230,23 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS const showSelfHostedMcpNotice = deploymentMode.isSelfHosted && state.mode === 'mcp' - const customPrompt = useMemo( - () => connectSchema.modes.find((m) => m.id === state.mode)?.prompt, - [state.mode] - ) - + const shouldFetchDataApiStatus = shouldFetchDataApiConfig({ + mode: state.mode, + }) + const { + isEnabled: isDataApiEnabled, + isPending: isDataApiConfigPending, + isError: isDataApiConfigError, + } = useIsDataApiEnabled({ + projectRef: ref, + enabled: shouldFetchDataApiStatus, + }) + const showDataApiDisabledWarning = shouldShowDataApiDisabledWarning({ + mode: state.mode, + isDataApiEnabled, + isPending: isDataApiConfigPending, + isError: isDataApiConfigError, + }) if (steps.length === 0) return null return ( @@ -208,6 +254,20 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS

Connect your app

+ {showDataApiDisabledWarning && ( + + Enable Data API + , + ]} + /> + )} + {showIpv4AddonNotice && ( )} - +
{steps.map((step, index) => ( diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.test.ts new file mode 100644 index 00000000000..d34db172b7d --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest' + +import type { ConnectMode } from './Connect.types' +import { + shouldFetchDataApiConfig, + shouldShowDataApiDisabledWarning, +} from './ConnectStepsSection.utils' + +const ALL_MODES: ConnectMode[] = ['framework', 'direct', 'orm', 'mcp', 'server'] +const MCP_WITH_DATABASE_TOOLS = { + mode: 'mcp' as const, + mcpFeatures: ['database'], +} + +describe('shouldFetchDataApiConfig', () => { + test('returns true for framework mode', () => { + expect(shouldFetchDataApiConfig({ mode: 'framework' })).toBe(true) + }) + + test.each(['direct', 'orm', 'server', 'mcp'] as const)('returns false for %s mode', (mode) => { + expect(shouldFetchDataApiConfig({ mode })).toBe(false) + }) + + test('returns false for mcp even when database tools are selected', () => { + expect(shouldFetchDataApiConfig(MCP_WITH_DATABASE_TOOLS)).toBe(false) + }) +}) + +describe('shouldShowDataApiDisabledWarning', () => { + test.each(ALL_MODES)('returns false while pending for %s mode', (mode) => { + expect( + shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled: false, + isPending: true, + isError: false, + }) + ).toBe(false) + }) + + test('returns false when config query errored', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: false, + isPending: false, + isError: true, + }) + ).toBe(false) + }) + + test('returns true for framework when Data API is disabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(true) + }) + + test.each(['direct', 'orm', 'server', 'mcp'] as const)( + 'returns false for %s when Data API is disabled', + (mode) => { + expect( + shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(false) + } + ) + + test('returns false for mcp with database tools selected when Data API is disabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + ...MCP_WITH_DATABASE_TOOLS, + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(false) + }) + + test('returns false when framework mode has Data API enabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: true, + isPending: false, + isError: false, + }) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts new file mode 100644 index 00000000000..95998f7a513 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts @@ -0,0 +1,20 @@ +import type { ConnectMode } from './Connect.types' + +export function shouldFetchDataApiConfig({ mode }: { mode: ConnectMode }): boolean { + return mode === 'framework' +} + +export function shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled, + isPending, + isError, +}: { + mode: ConnectMode + isDataApiEnabled: boolean + isPending: boolean + isError: boolean +}): boolean { + if (isPending || isError || isDataApiEnabled) return false + return shouldFetchDataApiConfig({ mode }) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx new file mode 100644 index 00000000000..7c8ea817b03 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx @@ -0,0 +1,19 @@ +import { Admonition } from 'ui-patterns/admonition' + +import { InlineLink } from '@/components/ui/InlineLink' +import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants' + +export const PasswordEncodingNote = () => { + return ( + + If your database password contains special characters, you will need to{' '} + percent-encode them + in the connection string. + + } + /> + ) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx index c9391840089..43a12e20516 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { EnvRow } from '../common/EnvRow' import { SecretEnvRow } from '../common/SecretRow' diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx index 0d768dd1741..5758dbd183a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx @@ -1,4 +1,4 @@ -import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ui' import CopyButton from '@/components/ui/CopyButton' @@ -11,20 +11,20 @@ const INSTALL_OPTIONS = [ function ServerInstallContent() { return ( - - + + {INSTALL_OPTIONS.map((option) => ( - {option.name} - + ))} - + {INSTALL_OPTIONS.map((option) => ( -
- + ))} - + ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index 65157cb03dc..64687afd842 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -26,6 +26,7 @@ import { PASSWORD_PLACEHOLDER, resolveConnectionString, } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote' import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog' import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' @@ -237,6 +238,7 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { {poolerBadge}
)} + {connectionString.includes(PASSWORD_PLACEHOLDER) && }
+ {config.passwordInUrl && } {(config.postCommands ?? []).map((command) => (
diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 0c4fc5348bb..85ca7e62f9f 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Download } from 'lucide-react' import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx index 941f6120b74..f0dec4bbc8b 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsEmpty.tsx @@ -1,5 +1,5 @@ import { DatabaseBackup } from 'lucide-react' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' export const BackupsEmpty = () => { return ( diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index fb3d5f8fc7c..edac39d4c11 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -4,9 +4,9 @@ import { Clock } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { toast } from 'sonner' -import { TimestampInfo } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { BackupItem } from './BackupItem' import { BackupsEmpty } from './BackupsEmpty' diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx index 17fa70ecfdc..63eb2035eb5 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx @@ -1,5 +1,5 @@ import { Badge, Button } from 'ui' -import { TimestampInfo } from 'ui-patterns' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { BackupsEmpty } from '../BackupsEmpty' import Panel from '@/components/ui/Panel' diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 12f18ffd3a7..0113fd47257 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -15,6 +15,9 @@ import { TableHead, TableHeader, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -23,9 +26,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import CreateEnumeratedTypeSidePanel from './CreateEnumeratedTypeSidePanel' import EditEnumeratedTypeSidePanel from './EditEnumeratedTypeSidePanel' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' -import SchemaSelector from '@/components/ui/SchemaSelector' +import { SchemaSelector } from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useEnumeratedTypeDeleteMutation } from '@/data/enumerated-types/enumerated-type-delete-mutation' import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query' @@ -235,13 +238,19 @@ export const EnumeratedTypes = () => { {!isSchemaLocked && (
- - {!isSchemaLocked && ( -
diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index df17cf08a7e..24f4763fa21 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -16,9 +16,10 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition, TimestampInfo } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { MigrationsEmptyState } from './MigrationsEmptyState' import { SupportLink } from '@/components/interfaces/Support/SupportLink' diff --git a/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx b/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx index 92862f0dc18..8a6e8ec769b 100644 --- a/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/MigrationsEmptyState.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Terminal } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import CommandRender from '@/components/interfaces/Functions/CommandRender' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts rename to apps/studio/components/interfaces/Database/Policies/Policies.constants.ts index 9e918f6cc11..2e1e7dcb2a7 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts @@ -1,6 +1,18 @@ import { safeSql } from '@supabase/pg-meta/src/pg-format' +import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' -import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants' +export interface PolicyTemplate { + id: string + preview: boolean + templateName: string + description: string + name: string + statement: string + definition: SafeSqlFragment + check: SafeSqlFragment + command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL' + roles: Array +} /** * ---------------------------------------------------------------- diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Database/Policies/Policies.tsx similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/Policies.tsx rename to apps/studio/components/interfaces/Database/Policies/Policies.tsx index 084d6b57ffe..b841b160ab4 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Database/Policies/Policies.tsx @@ -9,8 +9,8 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PolicyTableRow, PolicyTableRowProps, -} from '@/components/interfaces/Auth/Policies/PolicyTableRow' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +} from '@/components/interfaces/Database/Policies/PolicyTableRow' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' import { RLSToggleDialog } from '@/components/interfaces/Database/RLSToggleDialog' import { NoSearchResults } from '@/components/ui/NoSearchResults' diff --git a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx b/apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx similarity index 95% rename from apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx rename to apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx index 15d083b5935..a0cdef263c9 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PoliciesDataContext.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react' import { createContext, useCallback, useContext, useMemo } from 'react' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import type { ResponseError } from '@/types' type TableKey = `${string}.${string}` diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/LockedQuerySection.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/LockedQuerySection.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx similarity index 98% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx index ea7da926e6f..77d54208dad 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -17,7 +17,7 @@ import { getGeneralPolicyTemplates, getQueuePolicyTemplates, getRealtimePolicyTemplates, -} from '../PolicyEditorModal/PolicyEditorModal.constants' +} from '../Policies.constants' import { Markdown } from '@/components/interfaces/Markdown' import CardButton from '@/components/ui/CardButton' import CopyButton from '@/components/ui/CopyButton' diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/QueryError.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/QueryError.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/RLSCodeEditor.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/RLSCodeEditor.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx index 40e528b5c0d..4e59521d12b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx @@ -26,10 +26,10 @@ import { Sheet, SheetContent, SheetFooter, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' import * as z from 'zod' @@ -40,7 +40,7 @@ import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' import { PolicyTemplates } from './PolicyTemplates' import { QueryError } from './QueryError' import { RLSCodeEditor } from './RLSCodeEditor' -import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils' import { IStandaloneCodeEditor } from '@/components/interfaces/SQLEditor/SQLEditor.types' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -48,7 +48,7 @@ import { useDatabasePolicyUpdateMutation } from '@/data/database-policies/databa import { databasePoliciesKeys } from '@/data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' @@ -330,7 +330,7 @@ export const PolicyEditorPanel = memo(function ({ showClose={false} size={showTools ? 'lg' : 'default'} className={cn( - 'bg-surface-200 p-0 flex flex-row gap-0', + 'bg-popover p-0 flex flex-row gap-0', showTools ? 'min-w-screen! lg:min-w-[1000px]!' : 'min-w-screen! lg:min-w-[600px]!' )} > @@ -553,18 +553,18 @@ export const PolicyEditorPanel = memo(function ({ 'bg-studio overflow-auto' )} > - - - + + Templates - - + + - - - + +
)} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow.tsx similarity index 92% rename from apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx rename to apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow.tsx index 6a2a3a40da3..f9b6338a511 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyRow.tsx @@ -121,14 +121,20 @@ export const PolicyRow = ({ {!isLocked && ( - -