Compare commits

...

75 Commits

Author SHA1 Message Date
Nathan Voxland
2363e2d970 mage clj-ts: group the sidebar's files under directory headers
Each directory is a bold header with its files listed by name underneath;
non-Clojure files are dimmed.
2026-09-22 22:34:50 -05:00
Nathan Voxland
a3d94fef40 mage clj-ts: don't open a browser; print the base URL; rename Legend to Help
The server now only prints http://localhost:<port> (plus the page path when
started with --pr/--branch/a file), and / always redirects to /local. Removes
the now-unneeded --no-open option.
2026-09-22 22:26:30 -05:00
Nathan Voxland
71fec20eb0 Rename mage cljts to mage clj-ts
The task is now ./bin/mage clj-ts, with namespaces mage.clj-ts.*.
2026-09-22 22:22:30 -05:00
Nathan Voxland
ce69347dff Rename mage readable to mage cljts, and its Readable view to TypeScript
The task is now ./bin/mage cljts (namespaces mage.cljts.*), the per-file toggle
reads TypeScript | Clojure, and ?view=ts selects the translated view.
2026-09-22 22:20:37 -05:00
Nathan Voxland
4d7a8c3b67 mage readable: only show Clojure file contents
Non-Clojure files are still listed, but their bodies say "You know how TypeScript
works." (TS/JS) or "Not clojure" (everything else), and their contents aren't read
from git. A 167-file comparison page drops from 381 MB to 16 MB.
2026-09-22 22:09:43 -05:00
Nathan Voxland
757e9820f7 mage readable: per-file Readable/Clojure toggle that keeps the source line in place
- The translator now produces a source map (readable line -> Clojure line) from
  zero-width markers on statements, forms, comments, bindings and items.
- Every diff row carries its Clojure line on each side (data-n/data-o) and the
  first row per line gets a linkable anchor (#f3-n57).
- Each file has its own toggle; switching fetches just that file's other view and
  scrolls so the same Clojure line stays at the same screen position, expanding
  collapsed context if needed. The global toggle is gone; 'c' toggles the file at
  the top of the screen.
2026-09-22 22:04:12 -05:00
Nathan Voxland
31aca5ebf1 mage readable: open a PR number, PR URL, or branch from the header box
A branch is compared to its parent branch: the candidate (origin/master, release
branches, local branches) it forked from most recently, overridable with ?base=
or --base. Also adds --branch to the CLI.
2026-09-22 21:44:03 -05:00
Nathan Voxland
3adbda362d mage readable: read PR commits locally and process files in parallel
- Only git fetch when the PR head or base isn't in the local repository, logging
  the fetch; if it fails, the error page shows the command to run by hand.
- Translate/diff/highlight changed files concurrently on big-stack threads, and
  read file versions from git in parallel (25-file sample: 4.8s -> 1.3s).
2026-09-22 21:36:20 -05:00
Nathan Voxland
0548bc5a18 mage readable: show :pre/:post condition maps as types and asserts
Single-parameter type checks in :pre become parameter types (a Malli schema or
type hint wins, and the check is kept as `assert(x is T)`); other checks become
assert(...) statements. :post type checks become the return type, other :post
checks are listed as "Ensures:" in the doc comment. Previously the condition
map was rendered as a stray object-literal statement.
2026-09-22 21:25:39 -05:00
Nathan Voxland
726709c679 Add mage readable: Clojure as readable TypeScript-ish code for PR review
A local viewer (`./bin/mage readable`) that shows a GitHub PR, local changes, or
any file with Clojure deterministically translated into TypeScript-like code, as a
unified diff of the translated text with a toggle back to the raw Clojure diff.

- rewrite-clj based translator (keeps comments), layout via a small pretty printer
- rules for clojure.core, threading macros, Malli (input types, return
  descriptions), Toucan/HoneySQL rendered as SQL via HoneySQL itself,
  defendpoint, defsetting, defenterprise, methodical/Toucan hooks, and
  clojure.test / metabase.test helpers
- unknown forms fall back to generic calls or raw clj`...` blocks; all 3,849
  backend files translate without errors (`--stats`)
- server binds to localhost only and only serves files inside the repository
2026-09-22 20:42:47 -05:00
Eric Normand
8ce3936116 GHY-4642: Renaming a collection and exporting via Remote Sync doesn't update repo paths for content in renamed collection (#82865)
Remote sync: full export when a synced collection is renamed or moved (GHY-4642)

A collection path is also the directory of its contents. The incremental export wrote the renamed collection file and deleted the old one, but left every descendant file under the old directory, and kept their stale file_path values. Later incremental pushes did not repair it.

An update to a Collection whose stored path differs from its new path now falls back to a full export, which rewrites every path and records each file_path. This matches the import side, which already does a full import for any Collection change. In-place collection edits that keep the path stay incremental.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:11:38 -05:00
Oleksandr Yakushev
5bcd89d33f [pivot] Optimize BE+FE pivot generation (#82811) 2026-09-22 21:22:53 +03:00
Eric Normand
916ee6554c SEC-1208: Login throttle failures crash with fk_login_history_session_id FK violation, causing self-reinforcing lockout (#82859)
SEC-1208: test that a concurrent session delete cannot fail a login

Add an API test for POST /api/session. Between the session insert and the login_history insert, another connection deletes the user's sessions, as a password change does. The test logs in 11 times like this. Each login must return 200. Then a normal login must work, and GET /api/login-history/current must show all 12 logins.

The bug is already fixed. Commit 4a7f73029a (#80718, backported to 61 as #80962, first in v0.61.20.1) writes the session row and the login_history row in one transaction. The reporter ran v0.61.1, which does not have it. When that transaction is removed, this test fails as the ticket describes: each login returns 401 with an fk_login_history_session_id violation, and after 10 of them the throttle returns "Too many attempts" for the correct password.

The test does not use mt/with-temp for the user. with-temp binds a transaction connection, and the test client conveys it into the request, so the delete could not see the login's rows. For the same reason, the delete runs on a plain Thread, not a future.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:33:28 -05:00
github-automation-metabase
fff70175e0 Update translations 2026-09-22 (#82845)
* Update translation sources 2026-09-22

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-09-22 06:21:08 +00:00
Alexander Polyankin
74216b3098 Transform tests batch 2 (#82770) 2026-09-21 16:17:38 -04:00
github-automation-metabase
0509e1a5e2 Tighten ratchets (#82829)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-21 18:27:18 +00:00
Oleksandr Yakushev
81736f712a [kondo] Squelch missing-docstring linter for Potemkin-imported vars (#82812) 2026-09-21 21:21:36 +03:00
Michael Eby
812e9b2254 add IP allowlist page to nav (#82774)
* add IP allowlist page to nav

* fix nav and metabase ip addresses label
2026-09-21 12:40:46 -04:00
Timofey Kachalov
d0ce86c19e Fixed InteractiveQuestion and StaticQuestion flashing the "run your code" empty state instead of a loader while card.query is still unresolved (#82693) 2026-09-21 18:48:24 +04:00
Jeff Bruemmer
72e2e9b234 docs - ai providers updates (#82376) 2026-09-20 14:33:58 +00:00
github-automation-metabase
93742a629c [chore] Update E2E test timings with recent data (#82804)
update e2e timings 20260920-062425

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-20 07:03:45 +00:00
Nathan Voxland
b485c0f7fa Improve *-allowed-network validation (#82519) 2026-09-20 01:32:39 -05:00
Nathan Voxland
76979e6167 Limit unauthenticated request body sizes (#82572) 2026-09-19 15:32:34 -05:00
Nathan Voxland
da7629bba6 Serve no values for a filter whose widget is an Input box (#82535) 2026-09-19 15:32:12 -05:00
Nathan Voxland
280c1e7be2 Improve indexed-entity handling in xrays (#82699) 2026-09-19 14:50:48 -05:00
Aleksandr Lesnenko
3d924036db bind dev Docker ports, socket REPL, and rspack dev server to loopback only (#82707) 2026-09-19 10:27:04 +07:00
Chris Truter
883e944cce Preserve Slack attachment names in thread history when text is missing (#82631) 2026-09-19 02:48:34 +00:00
Tyler Plude
9d1c919f6b Move glossary entity_id migration from v65 to v64 for backport (#82776)
* move glossary entity_id migration from v65 to v64 for backport

* reset Liquibase changelog cache in glossary precondition test

* Scope glossary precondition test column check to the current schema
2026-09-18 18:06:53 -07:00
Tyler Plude
b08a2f0e65 [GHY-4593] Make remote sync robust against restarts (#82553)
* add remote_sync_task.last_heartbeat_at and a per-task heartbeat thread

* always close a remote sync task row when its worker exits

* close this JVM's running remote sync tasks on graceful shutdown

* reap stale remote sync tasks at boot and when the current-task endpoint observes one

* keep a committed remote sync version as the sync base when its task is cancelled or fails afterwards

* return the initiating user on remote sync task responses

* show interrupted, cancelled, and quiet states in the remote sync progress modal

* report remote sync import progress through every phase with one throttled reporter

* use with-dynamic-fn-redefs for the heartbeat touch-task! redefs in remote_sync_task_test

* log the ids of stale remote sync tasks when the reaper closes them

* fix migration

* materialize data apps inside the import's version transaction

* discover a running or stuck sync from a fresh tab

* drop the unreachable sync interrupted view
2026-09-18 15:43:48 -07:00
John Swanson
22b1a393dc Read c3p0 pool stats from the pool objects instead of JMX (#82760)
* Read c3p0 pool stats from the pool objects instead of JMX

The Prometheus c3p0 collector enumerated every pool MBean and read its
attributes over JMX. A JMX read locks the MBean and then the pool, while
pool construction locks the pool and then the MBean
(swaldman/c3p0#95). The collector guarded against this by holding the
warehouse pool guard, but the application, Quartz and semantic-search
pools are built outside that guard, so a scrape overlapping one of those
constructions deadlocked and then held the guard forever: a fresh pod
passed its health checks but never completed a warehouse query.

`connection-pool-info` now enumerates c3p0's own registry
(`C3P0Registry/getPooledDataSources`, a copy made under the registry
lock) and reads each `PoolBackedDataSource` with the default-user
getters the MBean used. No JMX, no `locking`. A pool closed mid-scrape
throws and is skipped. Metric families, labels and the bug-reporting
endpoint's output shape are unchanged.

Warehouse pools of a non-default connection type get a name suffix
(`-write`, `-admin`, `-transform`) so two pools of one warehouse no
longer share a `database` label and overwrite each other.

The regression test scrapes on one thread while another builds pools
through the app-db path and a third builds a warehouse pool through the
guarded path; on the old collector it deadlocks within a second.

* Disable c3p0 JMX MBean registration

The Prometheus collector no longer reads pool MBeans, but any other
in-process JMX reader (the Datadog agent's JMXFetch, JConsole, a JMX
exporter) can still deadlock a pool construction the same way
(swaldman/c3p0#95). Set the c3p0 management coordinator to the null
one in resources/c3p0.properties so no pool or registry bean is
registered. c3p0 reads the key once through its layered config, with
system properties last, so an operator can restore the beans with
-Dcom.mchange.v2.c3p0.management.ManagementCoordinator=...ActiveManagementCoordinator.

Verified with a standalone harness: with the property, 37534 pool
creations under a concurrent JMX reader over 30 s with no deadlock and
no c3p0 beans; with the -D override, the beans return and the harness
deadlocks in 0.5 s, as it does without the property.
2026-09-18 14:44:18 -07:00
appleby
f58c520891 Require channel write permission to send to a channel by id (#82608) 2026-09-18 14:15:33 -07:00
Nathan Voxland
f2364b1d27 Format the blocked table's ID as a plain number in error message (#82772)
Don't format ids in error messages as if they were normal numbers
2026-09-18 20:35:31 +00:00
github-automation-metabase
39bd87ede3 Tighten ratchets (#82777)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-18 20:20:06 +00:00
Tyler Plude
56232fc3f8 [GHY-4600] Improve memory allocation of remote sync (#82625)
* move the serdes :field normalize schema into a var

* extract the library once on a merging pull or push

* stream the export preflight extraction instead of realizing it

* index each merge side once for the merge and its casualties

* fix test

* share one registered :field normalize schema between serdes and resolve

* test the merging pull and push extract the library once
2026-09-18 13:14:18 -07:00
Michael Eby
61b6635f13 docs: IP allowlist (#82761)
* docs: IP allowlist

* edits from jeff
2026-09-18 15:16:45 -04:00
Alexander Polyankin
86b1f4bca4 Transform tests (#82495) 2026-09-18 14:50:16 -04:00
Eric Normand
ac1f4cdc00 Add a glossary tool to the MCP server (#82696)
* Let a v2 MCP tool describe itself from the instance's own data

deftool gains an optional :description-suffix, a fn of no arguments whose
result is appended to the tool description on every tools/list. The static
part stays in the manifest cache and only the suffix is recomputed, so a
suffix that reads the app DB doesn't drag the schema conversion along with it.

tools-hash skips it on purpose. The keepalive polls that hash every 30 seconds
on every open stream, so covering descriptions would buy a change notification
for a suffix at the price of its query per stream per tick. A suffix reaches a
client when it next lists tools instead.

* Add a glossary tool to the MCP server

glossary() returns every term with its definition; glossary(term) returns
one, matched case-insensitively, since a model echoes a term as it read it
rather than as it happens to be stored.

The term names ride the tool's own description rather than waiting behind the
call. A model only looks a word up when something tells it the word is worth
looking up, and the terms that most need a company definition are the ones
that read as ordinary English -- account, active user, churn -- which a model
believes it already understands. Metabot gets this for free by pasting the
whole glossary into every message it sends, because it owns the prompt; here
the description is the only channel that reaches the model unprompted.

Definitions stay behind the call, and the term list is capped at 100. Nothing
bounds a glossary -- no cap on entries, and a definition is TEXT -- and a
description long enough for a client to truncate costs more than its tail is
worth.

A client may cache a description for the life of its session, so a term added
mid-session shows up when that client next connects.

* Keep glossary out of the driver-test trigger set

mcp now uses glossary, and mcp's transitive dependents are effectively the
whole graph because some modules declare :uses :any. That made a glossary
change reach driver, query-processor and transforms, so every glossary edit
would have run the full driver suite. mcp is already excused from this for the
same reason; glossary joins it rather than raising max-allowed-count, since
none of it is driver logic.

Also fixes two whitespace-linter errors: a literal U+2028 landed in the
glossary test where an escape sequence was meant. The separator is now built
with char, which is what the test was trying to say anyway.

* Reject a :description-suffix that only takes one argument

ifn? was the wrong predicate. Keywords, maps, sets and vectors satisfy it but
are callable only with one argument, so they passed registration and then threw
ArityException at the first tools/list -- the deferred failure the guard exists
to prevent. Worse, the accepted registration sat in the shared registry, so the
throw came from listing any tool at all, not from the one that was wrong.

Vars stay allowed: one holding a fn of no arguments is callable, and :handler
is already registered as a var.

* Point the MCP server instructions at the glossary

The initialize instructions are the only channel that reaches the model before
any tool call, so this is where the glossary gets announced.

The line is unconditional on purpose. An instruction to look a term up when you
don't recognize it never fires, because the words that most need a company
definition are the ones that read as ordinary English -- account, active user,
churn -- which a model believes it already understands. So it asserts that
these terms matter and to fetch them before answering, with no qualifier about
which words. A test pins that: it fails if the line goes missing, and it fails
if the line grows a "don't recognize" style qualifier.

Static, so the instructions stay a compile-time constant. Whether an instance
has any terms at all is reported by the tool's own description instead.

* Instruct rather than list, and page the glossary listing

Three fixes from review.

The description no longer names the terms. The old cap was on the number of
terms, but the thing being protected is the length of a description clients
truncate -- Claude Code at 2048 characters. Seventy ordinary-length terms
produced 2444 characters with no pointer to the rest, so the client cut the
tail and the model saw a partial list it had every reason to read as the whole
glossary. That is worse than no list: a model believing the glossary is covered
never calls. So the description now carries the count and an unconditional
instruction to call before answering, which is inherently bounded and asks the
model to notice nothing.

glossary() is paged. It rendered every row, term and definition, into one
block, with definition a TEXT column that nothing bounds at any layer. Now
limit and offset through the usual list envelope, defaulting to 50 and matching
browse_collection -- the closest analogue, a plain unranked listing of rows
carrying prose.

glossary(term) returns every match. term is unique case-sensitively, so "ARR"
and "arr" are two entries with two meanings, and the lookup is deliberately
case-insensitive because a model echoes a term as it read it. Taking the first
match silently picked one and gave no sign the other existed.

* Make the glossary tool description static again

The suffix reported how many terms an instance defines. The only thing that
bought was letting a model skip one call on an instance with no glossary --
a call that is cheap and self-correcting, since it returns "no terms are
defined" and the model moves on. That does not pay for a public extension
point on deftool, its load-time validation, and a query of every glossary row
on every tools/list.

So the instruction moves into the static description and the machinery goes.
registry.clj is byte-identical to its state before this branch; every change
it had carried was in service of the suffix, including the list-tools split
that existed only so tools-hash could avoid computing one.

The instruction itself is unchanged in substance and still unconditional: call
glossary() before answering any question about this instance's data, with no
qualifier about which words, because the words that most need a company
definition read as ordinary English and the question will not say which ones
are defined.

* Simplify description

* Fixes

* Read the glossary through its module API, not its db namespace

The MCP tool required metabase.glossary.db, which meant exporting another
module's raw Toucan namespace just to read from it. metabase.glossary.core is
the module's stated API namespace and says so in its own docstring; it only
lacked a read, which is why db had to be exported at all.

So core gains entries, and db leaves the module's :api. Nothing outside the
module reaches into it now.

* Match the glossary tests to the simpler unknown-term error

The unknown-term error no longer names the terms that do exist, so the two
tests asserting it did were red. They now assert what it says: the term the
caller asked for.

The third was the cleaning guard, which had been riding on that error naming a
stored term. Retargeted onto a successful lookup, where stored text still
reaches prose the model reads as the server speaking. It now covers the stored
definition as well as the term -- definitions had no cleaning coverage before,
and the definition is the longer field and the one a payload would fit in.

The listing is deliberately not covered: list-content JSON-encodes its envelope
and wraps it raw, so it is bounded by JSON escaping rather than by clean, and
asserting otherwise would have been false.
2026-09-18 13:14:17 -05:00
Bartosz Rybacki
0e83852557 GDGT-3137: accept emoji up to 10 code points as a comment reaction (#82652)
* GDGT-3137: accept emoji up to 10 code points as a comment reaction

The emoji schema counted UTF-16 units, so the England flag (7 code
points, 14 units) got a 400. Postgres and MySQL count varchar(10) in
code points and already fit every picker emoji, so only the schema and
the H2 column change. The MySQL collation set by v63 stays untouched.
2026-09-18 20:04:39 +02:00
Nathan Voxland
61d0fa5d0f Check permissions on nested cards (#82600) 2026-09-18 13:02:41 -05:00
Kevin Kelleher
504b8ea9ee Enable explorations (#82594)
* Revert "Disable explorations (#82230)"

This reverts commit c505a9a785.

* add back test improvements

* Restore up-front profile validation in the agent endpoint

The "Disable explorations" PR (#82230) also fixed an unrelated bug: a
request naming a nonexistent profile was not rejected up front. The SSE
stream opened, the turn was persisted, and the conversation-title LLM
call was made before anything failed. Reverting that PR here dropped
the fix along with the explorations changes.

This re-applies only the validation: `profile-registered?` in the
profiles registry, and a 400 "Unknown profile" check in
`streaming-request` right after the profile id is resolved and before
`start-turn!` persists anything or the title job runs. Explorations
stay registered.

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-09-18 13:12:20 -04:00
Mahatthana (Kelvin) Nomsawadi
61ce133268 test: Fix failed split permissions modal test (#82757)
test: drop the wall clock from the split modal test

The test gave the metadata request a 100ms fetch-mock delay and then
asserted the loader was still showing. On a loaded CI runner the
render plus the awaits burn through that budget, the loader is gone
and getByTestId throws. Resolve the metadata response explicitly
instead, so the loading window does not depend on timing.

Fixes EMB-2405


Claude-Session: https://claude.ai/code/session_01GuH5ewGP1DzQs5jwNpTgWc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:54:18 +01:00
github-automation-metabase
f24251fb4e Auto-update documentation for master (#82710)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-18 11:51:01 -04:00
Timofey Kachalov
7994ad35d8 Add data-apps scope to BE endpoints to prevent calling unmarked endpoints from inside a data app (#79382) 2026-09-18 19:34:58 +04:00
Timofey Kachalov
eb845c2ea6 Add version field support to data_app.yaml (#82557) 2026-09-18 18:57:25 +04:00
John Beers
3d8a3901a9 docs: update URL configuration (#82744)
[docs] Add theme keyword
2026-09-18 09:11:12 -04:00
Oleksandr Yakushev
16bd231ba7 [spec] Prevent t2.transformed/in-transforms from throwing on :model/ModelIndexValue (#79634) 2026-09-18 13:00:59 +00:00
Maksym Yakubych
89da5b2bc6 Fix unreadable low-rating data complexity badge (#82665) 2026-09-18 12:00:54 +02:00
Romeo Van Snick
91f5798258 Make SQL editor typing responsive again (DEV-3545) (#82724)
* Stop reconfiguring the SQL editor on every keystroke (DEV-3545)

* Apply native editor edits outside the keystroke (DEV-3545)

* Defer editor edits with Mantine's debounced callback
2026-09-18 19:58:46 +10:00
Jakub Chodorowicz
e49c2e60af Gdgt 3238 release sdk v2 canary1 (#82733)
release v2 canary.1
2026-09-18 11:05:32 +02:00
Mahatthana (Kelvin) Nomsawadi
496db401a1 fix: keep permissions tabs visible while data loads (#82633)
* fix: keep permissions tabs visible while data loads

The Data tab returned a bare spinner until databases, groups and
permissions loaded, so the tabs above it vanished on slow networks.
The page layout now always renders and the spinner shows in its
content area, like the Collections tab.

Fixes EMB-2392

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg4bU1ueEbNhR2AYfm96T8

* fix: open the split permissions modal after load

The Data tab now renders its layout while data loads, so the one-time
split permissions modal could open over the spinner. Dismissing it
there turns the user setting off before the table it explains shows.
Only allow the modal once loading finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TuekFYoRQBgQoySZe6xB3k

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:05:46 +00:00
Kevin Kelleher
188c8f412f Fix failing Loki test caused by #82684 (#82713)
fix loki failure
2026-09-17 20:14:55 -07:00
Aleksandr Lesnenko
62831f5e4d infer db from query in transform_write mcp tool (#82461)
* infer db from query in transform_write mcp tool

* dedupe

* review

* fix
2026-09-17 23:20:26 +00:00
Romeo Van Snick
d5d6eba4c5 Move ErrorBoundary into common (#82624)
Claude-Session: https://claude.ai/code/session_01FeHJzstcAE34DTu4G6btjx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:16:47 +10:00
Romeo Van Snick
a3b46fdd3c Return a question from the card hook, not a builder (#82602)
* Return a question from useQuestionFromCard instead of a builder

* Stop exporting the provider factory selector, which has no callers

* Memoise the connected-table link url
2026-09-18 09:16:37 +10:00
Romeo Van Snick
61c0e2915e Stop thirteen sites reaching through question.metadata() (#82552)
* Read the model's table, the list's entity type and the native editor's table from their own requests

* Take the native preview's provider from the store and the metabot table names from Lib

* Let the question carry its own metadata into derived questions and steps

* Take the pristine table URL, the native editor's databases and the last-run card off the Metadata object

* Mock the databases endpoint in the inline SQL prompt spec
2026-09-18 09:16:23 +10:00
Eric Normand
5701125c34 Per-scope checkboxes on the OAuth consent page (GHY-4555) (#82527)
* List every MCP v2 tool regardless of token scopes (GHY-4543)

tools/list filtered out tools the token lacked scope for, so a client
holding a narrow grant never saw the write, SQL or delivery tools and
had nothing to attempt or step up for. Scope stays enforced at
tools/call; the disabled-tools and client-extension filters still apply.

With scopes out of the listing, list-tools and tools-hash drop their
token-scopes parameter, and the transport's tools-hash-fn becomes
zero-arity.

* Explain MCP v2 scope denials to the model (GHY-4543)

Clients replace a scope denial with their own text ("token expired",
"Insufficient scope", "Tool execution failed"), so the model tells the
user their login expired. Server instructions now say that failure is a
missing permission, and how the user reconnects to grant it. Every tool
description ends with a static sentence naming its required permission by
its consent-screen label, identical for every token: Claude Code caches the
first description it loads for the whole session.

* Answer MCP v2 scope denials with 403 insufficient_scope (GHY-4543)

A scope denial was an HTTP 200 carrying a JSON-RPC error or an isError
result, so clients never stepped up: Claude Code only saves a step-up
scope, and mcp-remote only starts one, on a real 403 with an
insufficient_scope WWW-Authenticate challenge. The registry gate and the
four in-handler scope checks now mark their refusal, and the transport
answers a single (non-batched) message with 403 plus a challenge whose
scope is the held v2 scopes plus the required one.

* Keep a DCR client's registration scope from narrowing its step-up (GHY-4543)

Claude Code registers with the scope it read from the protected-resource
metadata, then steps up on the same client_id with the wider scope from a
403 insufficient_scope challenge. oidc-provider validates a requested scope
against the client's registered scopes, so that step-up was refused with a
400 invalid_request. Once scopes_supported is narrowed to the baseline, no
Claude Code user could reach write, SQL, or delivery.

A dynamically registered client's ceiling is now its registered scopes plus
the default grant scopes, computed on read. Registering can widen the
ceiling (a client that registers for mb:full keeps it) but never narrows
it. RFC 8707 resource narrowing is unchanged.

* Declare each MCP v2 tool's OAuth scope in securitySchemes (GHY-4543)

ChatGPT only steps up for scopes a tool declares: without per-tool
securitySchemes it re-authorizes for its narrow login scope in a loop,
and with them it re-authorizes once for held plus declared scopes.
The scheme is derived from the tool's static :scope in the cached
manifest, so it is the same for every token.

* Advertise only the baseline MCP v2 scopes to new clients (GHY-4543)

The protected-resource metadata `scopes_supported` and the 401 challenge
`scope` now list only `agent:content:read agent:resource:read`, so users
who never need write, SQL, or delivery never grant them. Claude Code and
the Claude connectors take their first-login scope from the resource
metadata; ChatGPT takes it from the challenge. All of them reach the rest
through the 403 `insufficient_scope` step-up and per-tool
`securitySchemes`.

What the resource accepts is unchanged: RFC 8707 narrowing still keeps all
six v2 scopes, and the authorization-server metadata stays wide because
Codex requests that list on every login and cannot step up.

* List every MCP v2 resource regardless of token scopes (GHY-4543)

resources/list now matches tools/list: it is token-independent, so a client can
see the resources it could step up for, and a client that pre-fetches the MCP
Apps shells before stepping up to agent:query:run does not cache them as
missing. resources/read keeps its scope gate.

* Answer MCP v2 resources/read scope denials with 403 insufficient_scope (GHY-4543)

resources/list now shows every resource, so answering a denied read with
"Resource not found" hides nothing and leaves a client with no step-up
signal. A client that pre-fetches an MCP Apps shell with a baseline token
now gets the same challenge tools/call sends, naming agent:query:run.
Unknown URIs stay -32602; inside a batch the denial is an in-band -32600.

* Lead MCP v2 tool descriptions with their required permission (GHY-4543)

Clients truncate long tool descriptions (Claude Code at 2048 characters),
so the appended "Requires the ... permission" sentence was cut from
document_write and execute_query. The model needs that sentence to name
the missing permission after a scope denial, so it now opens every
description, and the server instructions say it starts the description.

* Serve every MCP v2 resource on resources/read regardless of token scopes (GHY-4543)

Claude Desktop reads an MCP Apps shell alongside the tool call. When that
read was refused with 403 insufficient_scope, Desktop never stepped up after
the tool call's own 403; with the shell served, it stepped up automatically
and retried. The shell carries no data and, for a token without the shell's
scope, no UI credential: read-resource now withholds the credential instead
of refusing the read. Chart data stays gated by the tool call,
refresh_ui_credential, and the UI-surface route costs.

* Restore the scope check on MCP v2 data resource reads (GHY-4543)

Only the MCP Apps UI shells need to be served to any token: a refused shell
read stopped Claude Desktop stepping up after the tool call's 403. The fields
catalog carries data and has no such constraint, so reading it without
agent:resource:read again gets the 403 insufficient_scope challenge (in-band
-32600 inside a batch). UI shells stay unscoped, minting a credential only for
a token holding their scope.

* Fit MCP v2 tool descriptions under Claude Code's 2048-character limit (GHY-4543)

Claude Code truncates tool descriptions at 2048 characters, so document_write and
execute_query lost their closing guidance, including execute_query's pointer away
from execute_sql for filtered-dashboard cards. Condense both, and get_content for
margin, deferring detail to learn() topics and argument schemas. A test now holds
every listed description to the limit.

Also correct docstrings and comments left stale by the resources/read and
tools/list scope changes and by credential delivery through refresh_ui_credential.

* Spell out the scope union in the MCP v2 challenge docstring (GHY-4543)

The whitespace linter rejects the confusable Unicode character U+222A.

* Add agent:query:run to the MCP v2 baseline scopes (GHY-4543)

Claude Desktop retries a tool after a step-up over a session that does not
declare MCP Apps support, so a stepped-up visualize_query is refused and the
first chart after a step-up never embeds. Querying and charting therefore
must not require a step-up. The protected-resource metadata, the 401
challenge and default-ask-scopes now advertise agent:content:read
agent:query:run agent:resource:read; writes, raw SQL and delivery still step
up.

* Point MCP v2 chart requests at visualize_query in the server instructions (GHY-4543)

The routing line named only execute_query, and clients like Codex load tool descriptions on demand, so chart requests could miss visualize_query.

* Mint only the scopes chosen on the OAuth consent decision (GHY-4555)

The signed scope stays the offered set. The user's choice arrives in a
separate, repeatable granted_scope field and may only narrow it: a choice
outside the offer is rejected as params_tampered, and an approval that
leaves nothing granted is a 400. Offered MCP baseline scopes are always
granted. The token response already echoes the code's scope, so it now
names exactly what was granted.

* Add per-scope checkboxes to the OAuth consent page (GHY-4555)

Each offered scope is a granted_scope checkbox inside the consent form.
The six MCP v2 scopes are listed least to most harmful, with any other
scope after them in request order. The MCP baseline scopes are ticked,
disabled, and marked as always granted; every other scope, mb:full
included, starts unticked. The full-access warning now sits in the
mb:full row.

* Gate native query handles in transform_write on agent:sql:run and the kill switch (GHY-4543)

A query handle is not proof the SQL gates were spent: /api/embed-mcp/drills
stores a native query under agent:query:run alone. transform_write now runs
the same scope and mcp-execute-sql-enabled checks on a handle that resolves
to native SQL as it does on an inline native definition, matching
question_write.

* Tighten resource registration, step-up scopes, and the DCR ceiling (GHY-4543)

- register-resource! takes a closed map and strips :ui?, so a data resource
  can never be served to a token without its scope.
- step-up-scopes matches held scopes instead of looking them up, so a token
  holding a wildcard such as agent:content:* keeps the surface scopes it
  covers when a client replaces its grant with the challenged scope. It now
  takes the single required scope both callers pass.
- The default grant ceiling is applied only while dynamic registration is
  enabled, so turning registration (or MCP) off stops widening existing
  dynamic clients. The merge is now a pure function with unit tests, and an
  HTTP test pins that static clients are never widened. The legacy-client
  migration test runs with registration disabled, where the stored snapshot
  alone decides, so it exercises the migration again.

* Debounce the OAuth consent decision and require a ticked scope (GHY-4568)

The consent page gets an inline script, nonce'd and opted into a
script-src nonce so a production CSP allows it. On submit it shows a
spinner on the clicked button and disables both, carrying the decision
in a hidden input since a disabled button is not submitted, so a second
click can no longer race the first. The buttons come back when the page
is restored from the bfcache. It also keeps Authorize disabled while no
scope checkbox is ticked (GHY-4555).

* Correct MCP v2 tool descriptions, the documents skill, and server instructions (GHY-4543)

- document_write lists the valid entity-link models again.
- The documents skill said edits store new_str literally; the tool parses it
  as Markdown, as edit-replacement-parses-markdown-test pins.
- execute_query no longer claims to save cards (its handles save as cards),
  and names the previous-stage field ref shape instead of an ambiguous
  column-name string.
- The server instructions attribute the rewritten permission failure to the
  client and cover resource reads, which get the same 403.

* Tell the MCP v2 model which scopes the connection holds (GHY-4555)

The initialize instructions are now built per request from the calling token's scopes. A scoped token is told which v2 surface permissions it has and lacks, by consent-screen label; an unrestricted caller gets no list. The instructions also stop claiming the consent screen has no per-permission choices: a permission the connection lacks starts unticked, so the model warns the user to tick it.

* Note the unticked consent checkbox in the MCP v2 insufficient_scope description (GHY-4555)

Tool and data-resource 403 challenges now end their error_description with a note that the permission starts unticked on the consent screen and the user must tick it. The README describes the new consent screen and the per-connection instructions.

* Rename the agent:resource:read consent label to "Read MCP resources" (GHY-4555)

* Tighten the MCP v2 scope instructions (GHY-4555)

The general paragraph states the scope-failure guidance once; the per-connection paragraph carries only the held and missing permissions and how far to trust that list.

* Pin the MCP v2 scope-denial and UI-credential contracts in tests (GHY-4543)

Closes gaps the review found, where removing a check would stay green:
- question_write and subscription_write in-handler denials carry
  :insufficient-scope at the registry, like alert_write's.
- A denied tools/call sent as a notification is a bare 202, no challenge.
- Wildcard and unrestricted tokens get the shell credential; the fields
  catalog mints none, over the registry and over HTTP.
- tool-and-resource-scopes-agree-test asserts the credential is realized,
  since any token now reads a shell.
- advertised-scopes-are-distinct-test covers the advertised baseline, which
  is a vector and can repeat, and its docstring says so.

* Clean up MCP v2 scope-denial helpers, header quoting, and docs (GHY-4543)

- Share one scope-denial message and detail builder between tool calls and
  resource reads, one step-up challenge helper, and a common
  throw-insufficient-scope! for the four in-handler scope checks.
- quoted-string replaces " and \ instead of backslash-escaping them, since
  RFC 6750 excludes both from scope and error_description values; the 401
  challenge now quotes through it too.
- Trim docstrings to their contract and move the reasons into comments.
- The unrestricted UI credential comes from any unrestricted session (cookie,
  API key, or an mb:full token), not only cookie sessions.
- Note that the DCR ceiling applies only while registration is enabled, and
  fix stale comments, test names and strings, long lines, order-sensitive
  scope assertions, and hard-coded consent labels in tests.

* List scope IDs only in the MCP v2 instructions, have the model make the call once the user agrees, and cap instructions at 2048 characters (GHY-4555)

* Pre-tick consent scopes the same app already holds (GHY-4555)

A step-up asks for the held scopes plus the new one, and every non-baseline box started unticked, so ticking only the new scope silently dropped a held one. A scope that a live access or refresh token of the same app holds now starts ticked but stays enabled. The same app is the same client_id, the exact same https redirect, or a loopback redirect with the same host, path and client name on any port, matched against the redirect this request delivers the code to.

* Narrow the same app's tokens when a held scope is unticked (GHY-4555)

Unticking a pre-ticked scope now removes exactly that scope string from the same app's other live access and refresh tokens, so the choice holds across windows. Only scopes offered on the screen and held by the app are removed; nothing is ever added, and a token is revoked only when no scope is left. This runs at the approve decision, after the code is issued, because the authorization code row has no field to carry the declined scopes to the token exchange without a migration.

* Tell the MCP v2 model that reconnecting before a refused call won't offer the permission (GHY-4555)

* Always widen dynamic OAuth clients to the MCP scopes (GHY-4543)

Claude Code registers with the baseline scopes and steps up on the same
client_id. With dynamic registration turned off, that step-up /authorize
was answered with a raw 400. A dynamic client now always gains the six v2
MCP scopes on read, each still behind consent; the remaining agent-API
ceiling is added only while registration is enabled. Registration itself
stays blocked.

* Close the MCP v2 scope test gaps (GHY-4543)

Cover the transform_write update path's native gates, the HTTP 403
insufficient_scope challenge question_write and transform_write send from
inside their handlers, saving a drill handle minted through
/api/embed-mcp/drills, and the closed resource registration schema. Give
the locale test a positive control, merge the two tools/list
token-independence tests into one that compares the whole payload, and
drop the permission-sentence truncation test the fit and starts-with
tests already cover.

* Tidy MCP v2 scope wording and naming (GHY-4543)

Rename registry/insufficient-scope to insufficient-scope-detail so it
does not read like the transport fn of the same name. Drop the server
instructions clause telling the model to read a resource's scope off an
error clients hide. Document agent:resource:read and the handler-internal
scope checks in the module README, correct the stale comments about UI
resources gating on agent:query:run, and wrap the lines this branch left
over 120 columns.

* Let the test fallback template win over a local frontend build (GHY-4543)

The MCP Apps shell renders from the built `embed-mcp.html` when one exists,
so a worktree that has run a frontend build shadowed the inline fallback the
credential tests install. The production template has embedded no
`uiCredential` since #81041, so those tests failed locally and passed on CI,
where backend-only runs produce no build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QT1v8on6aPT2UeHCojGj8

* Tell the MCP v2 model a permission can be removed mid-session (GHY-4555)

* Drop cross-connection scope narrowing from the OAuth consent decision (GHY-4555)

Unticking a scope the same app already held used to strip it from that app's
other live access and refresh tokens. Every verified defect in this PR lived in
that path:

- the authorization code and the approved event are persisted before the
  narrowing runs, and both sit inside one `try`, so a narrowing failure answers
  400 while a redeemable code exists and the old tokens keep the scope;
- dynamic client registration is open, so an attacker can register a client
  carrying another app's https redirect -- or a loopback redirect with a copied
  `client_name`, since the port is ignored -- and strip or revoke the real app's
  tokens after phishing a single Authorize click;
- a pending authorization code is not narrowed, so redeeming one mints a
  just-removed scope straight back.

No comparable provider does this: GitHub, Google, Microsoft and Slack all leave
an existing grant alone when a new authorization asks for less. Taking a granted
permission away deserves its own flow, with its own confirmation and audit
trail; a follow-up ticket covers it.

What stays is the pre-tick: the consent page still ticks a scope the same app
already holds, so a step-up does not silently drop it, and the same-app matching
that finds those tokens is unchanged. A test now pins that an untick leaves the
app's other live tokens -- access and refresh -- exactly as they were.

Also drops the two db.clj scope-update helpers nothing else used, and corrects
two claims the removal falsifies: the MCP `initialize` instructions no longer
tell the model that another window can untick a permission mid-session (a
re-authorization without it, or a revocation, is how it goes away now), and the
MCP README says the connection's scopes are listed by scope ID rather than by
consent-screen label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Store a repeated OAuth scope once in the granted token (GHY-4555)

`scope=agent:content:write agent:content:write` was split without
deduplication, and the decision filters the grant from that list, so the
duplicate reached the token row and the token response's `scope`. The consent
page already deduplicated for display, so nothing on screen hinted at it.

Deduplicate in `scope-tokens` instead, which is the one place both the page and
the decision read the requested scopes from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Pre-tick the OAuth consent page from the app's newest live grant (GHY-4555)

The held set was the union of every live token the app had for the user. A
refresh token outlives several authorizations, so the union let an old, wider
grant keep setting the defaults: day 1 grants content:write and sql:run with a
30-day refresh token, day 5 the user re-authorizes and leaves sql:run unticked,
and on day 10 the union pre-ticks sql:run again from the day-1 token -- silently
undoing the day-5 decision every time they re-authorize.

Take the newest live grant instead, so the user's latest choice is the default.
A grant's access and refresh rows are written by one token request and share a
`created_at`, so the newest timestamp names a grant rather than a row: every row
carrying it speaks for that grant, which is what keeps the newest grant readable
once its access token has expired and only its refresh token is left. Erring
narrow is safe now that an untick only shapes the token being minted.

Also records why the loopback key ignores the port, and what that costs, next to
the branch that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop consent-page pre-ticking of already-held scopes (GHY-4555)

Every non-baseline scope now renders unticked, whatever the user already
holds. Pre-ticking needed to identify the connection being stepped up,
and the consent page cannot:

- the step-up challenge expands a wildcard grant like `agent:content:*`
  into concrete scopes, but the held check matched exact strings, so
  `content:write` started unticked and a step-up dropped it;
- refresh rotation writes new rows with a current `created_at`, so an
  older broad grant refreshed after a newer narrow one looked newest and
  re-ticked a scope the user had dropped;
- the newest app-wide grant is not necessarily the connection stepping
  up: window A holds content:write, window B authorizes later without
  it, then A steps up and gets B's defaults;
- prechecking across registrations is a consent boundary. Registration
  is unauthenticated, so a local app copying the client name and
  loopback callback path, or a client registering another app's https
  redirect, got those scopes from one generic Authorize click instead of
  the user selecting them.

Doing it properly needs grant identity carried into the authorize flow,
the redirect recorded on tokens, and wildcard-aware matching. GHY-4627
tracks that.

Removes `metabase.oauth-server.held-scopes` and the `db` helpers that
existed only for it. A test pins the removal: with live access and
refresh tokens holding `agent:content:write`, the consent page still
offers it unticked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct two MCP scope claims in the README (GHY-4555)

Per-tool `securitySchemes` is draft SEP-1488 (modelcontextprotocol issue
1488), supported by ChatGPT. It is not part of MCP 2025-03-26, the
version this server reports, nor of the final 2026-07-28 tools spec, so
other clients learn a missing scope from the 403 instead. The comment
above `default-ask-scopes` implied it was generally available too.

Automatic scope step-up is likewise defined in MCP 2026-07-28, so
clients are not required to implement it against this server: a scope
left unticked can trigger another step-up in clients that support it,
while others need manual reauthorization.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the per-connection permission list from the MCP v2 instructions (GHY-4555)

The list caused the failure the "make the call anyway" clause was added to
fix: with it in context the model sometimes refused a write without calling
the tool, so no 403 came back, the client saved no step-up scope, and the
user's re-authorization offered only the baseline — the permission never
appeared as a checkbox. Without the list and without that clause, the model
calls the tool, reads the 403, names the permission and gives the reconnect
steps.

So the `initialize` instructions are one static string again: general
scope-failure guidance, plus one sentence that every optional permission on
the consent screen starts unticked, so the user re-ticks the ones they want
to keep. `transport.clj` goes back to a plain `:instructions` option.

* Tell the MCP v2 model the tool list is not a permission grant (GHY-4555)

Every tool is listed whatever the token holds, but a scope-filtered list is
the conventional design and the protocol has no field to signal ours. Asked
what a baseline connection could do, the model read the roster as a grant and
answered that it held every agent scope, reasoning that the server filters
tools by granted scope -- true of Metabase until the filter was removed.

Only a failed call reveals a missing permission, so say that.

* Fix counterfactual docstrings and pin the offered scope non-blank (GHY-4555)

Two docstrings stated the behavior the dedup prevents as though it were the
behavior it has, so a reader chasing the invariant learned the wrong one.
Both now say "would be stored twice".

scope-to-grant gains a postcondition. The decision endpoint answers a nil
grant with 403 params_tampered, which is only the right status while the sole
way to reach it is a choice naming an unoffered scope. That holds because
scope-to-grant throws rather than returning a blank offer; the postcondition
turns a future relaxation into a failing assertion instead of an honest
approval reported as tampering.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:34:41 -05:00
Ryan Laurie
ecb8fe82b2 Merge automated PRs with the automation user's token so CI runs (#82701)
GitHub starts no workflow run for a push made with `GITHUB_TOKEN`, so
enabling auto-merge with it lands backports on release branches, and
translation updates on master, with no CI at all. Approving still uses
the other token, since a token cannot approve its own pull request.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:19:51 +00:00
Kevin Kelleher
40ebe0bb9f Fix static viz Row Chart failures on remapped columns (#82684)
* make remapping idempotent

* copy the existing remapping so it doesn't get mutated
2026-09-17 19:55:42 +00:00
Eric Normand
0700179dad GHY-4542: Reject unregistered and empty OAuth scopes; fail closed on scope-less bearer tokens (#82498)
* Reject unregistered scopes in OAuth dynamic client registration

POST /oauth/register is unauthenticated and stored any scope string the
client sent. A client's registered scopes are the ceiling /oauth/authorize
checks requests against, so a self-nominated `*` or `agent:*` could later
be requested and granted, and scope-matches? treats both as wildcards.

Registration now answers 400 invalid_client_metadata, naming the offending
scopes, when any requested scope is not a registered scope. An omitted
scope still gets the default ceiling, and `mb:full` is still accepted.

* Reject unregistered scopes at OAuth authorize

Clients that registered a wildcard such as `*` before registration
validated scopes can still request it, since /oauth/authorize only checks
a request against the client's own registration. /authorize now answers
400 invalid_scope when any requested scope is not a registered scope. The
check runs before resource narrowing, which would otherwise drop the
scope when `resource` is sent and keep it when it is not.

Both endpoints now use one error_description that points the client at
the authorization server metadata listing scopes_supported, instead of
echoing the scopes it sent.

Clients registered on v0.60-v0.61 still hold agent:database:read,
agent:table:read, and agent:metric:read, which are no longer registered;
requesting them now fails. The authorize test fixtures move from the
unregistered "profile" to agent:content:read.

* Reject OAuth authorize requests without a scope

A request to /oauth/authorize with no scope rendered a consent screen
listing no permissions and minted a token with no scopes. It now answers
400 invalid_scope whether scope is absent, empty, or whitespace. A blank
scope is dropped before the provider validates the request, so an unknown
client or mismatched redirect URI is still reported first.

The error_description names no request input and points at the
authorization server metadata listing scopes_supported.

The OAuth authorizations E2E spec drove consent without a scope; it now
requests agent:content:read.

* Reject an empty scope in OAuth dynamic client registration

A registration that sent scope as null, an empty string, or whitespace
was stored with no scopes, and such a client can never authorize now
that /oauth/authorize requires a scope. /oauth/register answers 400
invalid_client_metadata instead, with an error_description pointing at
scopes_supported.

Omitting scope still registers the client with the default ceiling, which
is what MCP clients that register without a scope rely on.

* Fail closed on OAuth bearer requests without token scopes

Nil :token-scopes means scope-unaware auth, and sessions, API keys,
the scope middleware, the agent API, and the MCP transport all let it
through as unrestricted. For an OAuth access token, only an empty set
surviving every hop kept a scope-less token from becoming unrestricted.

The session middleware no longer authenticates an access token with no
scopes, and marks requests it does authenticate with an OAuth token as
:authenticated-via-oauth?. Behind it, enforce-scope and
ensure-scopes-checked answer 403, the agent API stops defaulting nil
scopes to unrestricted, and the MCP transport answers its invalid_token
401 for an OAuth-authenticated request with no scopes.

The marker is set by the session middleware rather than inferred from an
Authorization: Bearer header, which the agent API JWT path also uses and
which a session request may carry alongside its session. Sessions, API
keys, JWT, and the MCP Apps UI credential never set it.

* Redirect OAuth authorize errors to the client per RFC 6749

GET /oauth/authorize answered every validation failure with a 400 JSON
body, which strands the user on a raw error in their browser tab and
leaves the client waiting for a callback that never arrives.

The endpoint now looks up the client and checks the redirect_uri is an
exact match for one it registered before validating anything else. If
either is missing or invalid, it keeps the non-redirect 400, as RFC 6749
section 4.1.2.1 requires. Every other failure redirects to that
registered redirect_uri with error, error_description, state, and the
RFC 9207 iss parameter, built by the library's denial redirect builder.

Error codes: invalid_scope for unregistered, missing, fully narrowed,
and unregistered-for-the-client scopes (the last was mislabeled
invalid_request); unsupported_response_type for a response_type the
client did not register; invalid_target for an invalid resource
indicator; invalid_request for PKCE problems and malformed parameters.

* Send RFC 6750 bearer challenges on scope-denial 403s and agent API 401s

RFC 6750 section 3 requires a resource server to answer a bearer token
that does not grant access with a WWW-Authenticate challenge. The scope
middleware's 403s, including the fail-closed ones for OAuth requests
without scopes, sent only a JSON body, so an OAuth client could not tell
it should re-authorize for more scope.

enforce-scope and ensure-scopes-checked now add
  Bearer error="insufficient_scope", scope="...", error_description="..."
with scope naming the endpoint's required scope, and omitted in
ensure-scopes-checked, where the endpoint declares none. The format and
value sanitizing match the MCP transport's insufficient_scope challenge.
The JSON bodies are unchanged.

The agent API's 401 for a bearer token that does not authenticate now
carries Bearer error="invalid_token".

* Answer malformed OAuth resource indicators with client errors, not 500s

A resource value that is not syntactically a URI, such as
"http://bad uri", made oidc-provider's resource check throw a
URISyntaxException rather than an ex-info. Nothing caught it, so
/oauth/authorize, /oauth/authorize/decision, and /oauth/token all
answered a client input error with a 500.

Each endpoint now checks that every resource indicator parses before
handing the request to the library:

- /oauth/authorize: once the client and redirect_uri are validated, an
  RFC 8707 invalid_target error redirect. Otherwise the existing
  no-redirect 400.
- /oauth/authorize/decision: the existing 400 invalid_request, with no
  redirect, since its parameters are untrusted until the signature
  verifies.
- /oauth/token: a 400 invalid_target.

Only URISyntaxException is caught, and only while parsing the resource
values.

* Send a plain Bearer challenge on agent API 401s without a bearer token

RFC 7235 requires a WWW-Authenticate challenge on every 401. The agent
API's missing_authorization 401 (no credentials) and
invalid_authorization_format 401 (a non-Bearer Authorization scheme)
sent only a JSON body.

Both now carry WWW-Authenticate: Bearer. There is no error attribute,
because RFC 6750 section 3.1 says a challenge to a request with no
bearer token should not include an error code. Status codes and bodies
are unchanged.

* Keep OAuth authorize errors non-redirecting

Delivering /oauth/authorize errors as a redirect to the client's
registered redirect_uri made Metabase a zero-click open redirector.
Dynamic client registration is unauthenticated, so anyone can register
a client whose redirect_uri points at their own site, and a single
/oauth/authorize link on the trusted Metabase host would then send any
logged-in user there with no consent step. RFC 9700 section 4.11 says to
answer with an error rather than redirect when the redirect URI is not
trusted.

Every error from GET /oauth/authorize is a 400 JSON body rendered in the
user's browser again, with no Location header. The login redirect for
unauthenticated users is unchanged.

The error codes stay accurate: a scope outside the client's registered
set is invalid_scope, an unregistered response_type is
unsupported_response_type, and an invalid resource indicator, including
one that is not syntactically a URI, is invalid_target. Malformed
resource indicators still cannot reach the 500 they used to.

* Drop unregistered scopes at /oauth/authorize instead of refusing

Registration and authorization see the same problem from different
sides. An error from /oauth/register reaches the client program at setup
time, where it can act on it, so that endpoint keeps rejecting
unregistered scopes outright. An error from /oauth/authorize lands in a
user's browser tab as JSON while the client waits for a callback, and it
also breaks step-up for a client that still holds a scope shipped in
v0.60-v0.61 and since removed from the registry.

/oauth/authorize now filters the requested scopes down to the registered
ones and carries on with what survives, which RFC 6749 section 3.3
allows. Filtering runs before resource narrowing, so a request behaves
the same with and without a resource indicator, and the consent page and
the minted token carry exactly the surviving scopes. Least privilege is
unchanged: an unregistered scope, including a wildcard a client
registered before registration validated scopes, still can never be
granted.

A request in which nothing survives the filter is refused with
invalid_scope and its own description, distinct from the one for a
request narrowed away by its resource indicator. A missing, empty, or
whitespace-only scope is unchanged.

* Enforce the scope rules where the authorization code is issued

The HMAC over the consent form is keyed by the CSRF token, which the
consent page prints for the user, so the user can recompute it over
whatever scope they like. It proves no third party tampered with the
form; it does not prove the scope was ever validated.
/oauth/authorize/decision is what issues the code, so it now applies the
scope rules itself instead of trusting the signature.

Both endpoints call one scope-to-grant function: refuse a missing or
blank scope, drop the scopes that are not registered, refuse when none
survive, and grant exactly what is left. A denial grants nothing and
skips it. /oauth/authorize behaves as before; a refusal at the decision
endpoint keeps its existing 400 JSON shape and never redirects.

A correctly signed form asking for a scope the consent screen never
showed was previously honored: a re-signed wildcard minted a token
carrying it, and a re-signed blank scope minted one with no scopes at
all.

* Tidy the OAuth scope and challenge code

Follows the style review. No behavior change except one described below.

- narrow-scope-to-resource's docstring described a contract it no longer
  has: that nil means both "nothing requested" and "nothing survived",
  that the caller may drop the parameter, and that narrowing can never
  turn a valid authorization into a rejected one. It returns nil when no
  requested scope survives, and the caller refuses.
- One check-resource-parseable! replaces the same pre-check written three
  times with three different ex-data shapes, and the note about why it
  exists sits on unparseable-resource! once instead of at each call site.
- invalid_target now always carries the description saying what a valid
  resource indicator looks like. It used to depend on where the error
  came from: the pre-check said so, while a relative indicator or one
  with a fragment fell back to the generic description. This is the one
  behavior change, and the authorize test that pinned the generic
  description is updated.
- The three inline invalid_client_metadata 400s in /register go through
  one helper.
- The error_description strings and their helpers move out of the
  module's core namespace, where they were public with a single caller,
  into the api namespace as private defs grouped together.
- The MCP transport's two invalid_token 401s were identical; they share
  one response now.
- The agent API's error-response takes the WWW-Authenticate challenge as
  an argument, which is what all three of its callers were adding
  afterwards.
- Dropped an inaccurate comment about parameter order matching the MCP
  transport's challenge, some rationale that belongs in line comments
  rather than docstrings, and a double negative.

* Tighten the OAuth scope and challenge tests

Follows the style review.

- quoted-string is now exercised directly with a quote, a backslash and
  non-ASCII input. The charset assertion it replaces only ever saw
  constants, so it could not fail.
- The agent API's invalid_token 401 test moves to the agent API tests,
  where the behavior lives, and its no-credentials case goes: that is
  already covered there.
- Deleted bearer-token-without-scopes-gets-no-tool-access-test. Its 401
  comes from the transport's invalid-bearer branch, because the session
  middleware declines a scope-less token before the transport sees it, so
  it passed with or without the fail-closed branch and duplicated
  bearer-bridge-refuses-token-without-scopes-test. The branch itself is
  covered by oauth-request-without-token-scopes-is-refused-by-the-
  transport-test, which invokes the handler directly.
- The DCR unregistered-scope test compares the whole error_description
  instead of a substring plus assertions that cannot fail.
- Scope-denial tests assert the exact error code each case produces
  rather than accepting either.
- A test description stated its own history; it states the behavior now.
- Wrapped lines over 120 characters.

* Declare :authenticated-via-oauth? in the request schemas

Master closed ::current-user-info and added a closed ::request schema
enumerating every key the middleware stack may attach. This branch marks
a request the OAuth bearer bridge authenticated with
:authenticated-via-oauth?, which both schemas then rejected as an extra
key: the bridge threw on its return value, so no OAuth bearer request
could authenticate.

* Describe only our own resource indicators as malformed

The error_description helper answered every invalid_target that carried no
description of its own with the one describing a malformed resource indicator.
oidc-provider raises that code without a description for a different condition:
a refresh whose resource is outside the original grant. A client sending a
well-formed absolute URI was told its URI was malformed.

The endpoint's own check now carries its description in the exception, and the
helper falls back to the endpoint's generic description for anything else. That
check also validates what it claims to describe, so a relative indicator or one
with a fragment is answered by it rather than by the library.

* Answer 401 when no JWT provider is registered

The agent API's stateless-JWT fallback dispatches auth-identity/authenticate
on :provider/jwt. That provider ships only in EE, so on OSS nothing derives
the keyword into the auth-identity hierarchy and the dispatch throws
UnsupportedOperationException. Every bearer token the OAuth bridge declines
falls through to it, so the agent API answered 500 with a stack trace instead
of the RFC 6750 invalid_token challenge it owes the request.

Treat an unregistered provider the way a disabled one is treated: the same
jwt_not_configured result, which enforce-authentication already renders as a
401 carrying the invalid_token challenge.

Also stop bearer-bridge-refuses-token-without-scopes-test from holding
with-temp's rollback-only transaction open across its four HTTP round-trips.
The request runs on a conveyed connectable, so anything in its path that
commits implicitly invalidates the savepoint and the scope exit throws
instead of rolling back.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:57:02 -05:00
Nathan Voxland
e96a5d884d Improve OSS include_editable_data_model logic (#82451) 2026-09-17 13:40:38 -05:00
Nathan Voxland
587b659ee7 Don't include archived cards in embedded dashboards (#82529) 2026-09-17 13:39:39 -05:00
Andrei Simionescu
4b6744c1f4 Apply the chat system prompt to Metabot in Slack (#82639) 2026-09-17 18:06:54 +01:00
Andrei Simionescu
4c5146d536 Don't feed Metabot queries the user has no permission to run (#80083)
* BOT-1956: Fix permission-check gaps in transform/query export

Card/measure/segment export lookups routed through the loud, audit-logging
read-check even for routine filtering, one query-export call site defaulted
to the unchecked store, and a withheld query left a dangling header instead
of saying so.

* BOT-1956: Audit the permission check on client-supplied query ids

Ids arriving inline in the viewing context are a real access attempt, so the database gate
and the card ids inside the query both keep the audited read-check while stored-query export
stays quiet. export-query-for-llm now takes an explicit content store, so every call site
shows which one it picked.

* Don't audit permission refusals the caller discards (#79969)

* BOT-1956: Resolve -1337 to the real database and gate transform sources

The virtual id passed the database gate as a 404 while the export then
resolved it to the source card's real database, so the gate now resolves
first and fails closed on ids it cannot. Transform sources get the same
gate, queries and charts seeded from the viewing context keep the audit
trail when a read_resource denial withholds them, and the withheld
message no longer blames the database when a card inside the query was
the refusal.

* BOT-1956: Drop the em dashes from two docstrings

* BOT-1997: Don't feed Metabot queries the user has no permission to run

* BOT-1997: Shorten the comments on the Metabot permission checks

* BOT-1997: Close the two remaining fail-open paths in the query export

Exports now defer to the query processor's can-run-query?, the same
check the save path uses, instead of a database-wide can-query?
shortcut plus SQL analysis. For native stages that means the
database-level query-builder-and-native rule with no parsing: the
analyzer was best-effort and could return an incomplete table set with
no completeness signal, so a clean analysis never proved the query was
authorized, and unsupported drivers withheld queries from fully
authorized users. Deferring to the QP contract also keeps queries
sourced from saved questions working; those authorize through the
card's collection, not through database query permission. The
name-resolution checks (queryable tables, sandbox-visible fields,
readable cards) stay on top of the run check.

The same check now also covers conversation state. read_resource used
to export a stored query after only a database read-check, so a user
who could read the database's metadata but not run the query could
recover resolved table and field names through metabase://query/{id}
and metabase://chart/{id}. Both fetchers now share the viewing-context
formatter's exportable-query? check.

* BOT-1956: correct three stale comments

The withheld-message docstring claimed a deleted database is withheld too, when it
deliberately renders. The content-store test docstring counted three pinned properties
while listing four, and the repair comment called every pass best-effort when only the
mini-resolve lookups are.

* BOT-1997: authorize conversation queries by run permission alone

The query and chart resources read-checked the raw :database before the run-permission
check, which broke the two cases the canonical check exists for: a collection-only user
running a saved-question query got a 403 from a database they never touch, and a legacy
-1337 database id 404ed before normalization could resolve it through the source card.
The read-check is gone and the viewing-context predicate now lets normalization resolve
-1337 instead of gating on the raw id.

The source-card tests also stop granting manage-table-metadata, which had made the
database readable and hidden exactly these cases; they now run with no database
permission at all, over both the real and the -1337 id, alongside a new refusal test
for a card in an unreadable collection.

* BOT-1997: Hide transform source queries the user can't run

Reading a transform is authorized per database, but rendering its source resolves
table and field ids to names, so the details tool and the transform resource now
drop the query unless the user could run it. The viewing-context path also stops
normalizing twice and logs a query it can't permission-check at debug, instead of
letting the calculator log an error on every prompt.

* BOT-1956: make the query export gates table-granular

* BOT-1956: fail closed when a native query can't be analyzed

* BOT-1956: Fold the two query database gates into one with an audited? flag

* BOT-1997: Calculate perms inside the try, assert the 403 text in tests

* BOT-1956: Check exports with can-run-query? instead of parsing native SQL

* Build the saved-question test card with Lib instead of the deprecated mbql-query

* Use metabot.db/database-exists? instead of a direct Toucan call

* BOT-1956: Name can-run-query?'s flag for the errors it rethrows

* BOT-1956: Suppress repair's refusal auditing at its call site

* BOT-1956: Keep :client-ids out of the conversation state

The ids describe the viewing context of the request being served rather than the conversation, and final-state-part was handing them back to the client as if they were state.

* BOT-1956: Gate a query export with one Database read and one normalization

* BOT-1956: Audit a chart's refusal against the query it was built from

* BOT-1956: Make run permission the whole gate on a query export

Reading the database was checked on top of it, which withheld a saved question from a user who can read the card and run it but has no grant on the database behind it. Dropping that check also leaves one Database lookup where there were two, so a deletion landing between them can no longer escape as a 404.

* BOT-1997: Render a query we could not permission-check rather than hiding it

A query that will not normalize, or whose permissions will not calculate, was reading as a refusal and disappearing from the transform output and the conversation resources, admins included. It now comes back without a metadata provider and renders through the paths that resolve no ids to names, which is what master did with its EDN fallback.

* BOT-1997: Cover the cases the export gate decides on a transform source

* BOT-1997: cljfmt

* BOT-1997: Make the unresolved fallback actually withhold names

A query that will not parse never reached the fallback, because normalization degrades to an empty map unless it is asked to be strict, so those queries still read as refusals and disappeared. Transform rendering then threw away the checked query and its provider and resolved the source a second time through an unrestricted provider, which put resolved names back in front of the model for a query whose table or sandbox lookup had thrown. Native SQL needed its own answer either way, since its table and column names are already spelled out in the text and having no provider hides nothing, so it stays out until the check has actually run. Rendering now happens where the check's result is still in hand, and an export failure falls back to the query the caller passed rather than the normalized form.

* BOT-1956: Keep a client query's provenance for as long as the query lives

Carried over from the review on #79877, which merged before this was picked up. The ids of queries and charts the client sent were request-local, so a tool could persist one of those queries and a later turn read it back after it had left the viewing context, by which point it looked tool-written and a refusal to show it went unaudited. They live in conversation state again, declared in the state schema this time, and each turn adds to what earlier turns recorded.

* BOT-1997: Tell a check that was never needed from one that could not be made

Both came back as a query with no metadata provider, which conflated two different situations. A transform whose source database has been deleted has nothing behind it to resolve, and an admin is supposed to still read that source while repairing it, but it was being withheld like a query whose permissions had thrown. Going the other way, native SQL was only held back when the source had a single stage, because the check for it was the helper that extracts a complete query's SQL and deliberately gives up on multi-stage queries, so a native stage under an MBQL stage still printed its SQL in the unresolved fallback. The two now come back distinctly, and native is recognised by looking for a native stage anywhere in the query.

* BOT-1956: Take client ids back in the shape the app DB returns them

JSON has no sets, so state read back out of metabot_message carries the ids as a vector, and the agent entry point validates its input before memory initialization turns it back into a set. Every conversation with a stored turn therefore failed to start its next one under dev and test instrumentation. The state schema accepts either shape now and normalization settles it, and the test covers the round trip rather than handing a set straight in.

* Hide native SQL from the viewing context when perms can't be checked

* Use with-dynamic-fn-redefs in the sandboxed field transform test

* Hide native SQL from read_resource when perms can't be checked

* Treat an unresolvable database id as an unchecked query

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-17 18:01:18 +01:00
Chris Truter
e28ae43d1d Various Slackbot upload improvements (#82216)
* Tidy up slackbot CSV upload handling

Report a file that cannot be staged on disk as one failed file rather than
letting the IOException escape to the async handler, which only logs and leaves
the Slack user with no reply at all.

Take the Slack client from the caller instead of re-reading the app token per
file, fetch the uploads Database once, build the upload target in kebab-case,
and express pre-flight failures as system messages so the API namespace stops
hand-rolling its own.

* Trim the upload docstrings and comments to their contracts

Drop the note about the upload module's private `allowed-extensions`: nothing here
relies on that set, so it would go stale silently. The `csv-file?` test asserts
`txt` is not uploadable instead, which fails if anyone widens the set.

* Polish Slackbot upload handling

* Test attachment-only upload failures

* Clarify Slackbot upload return contracts

* Regenerate the modules config after dropping the Database lookup

* Tighten the upload docstrings and comments

* Close the response stream when a redirect Location will not resolve

URI.resolve throws IllegalArgumentException on a malformed Location, and redirect-target runs
in the let binding that computes target, before the cond reaches either .close branch. A Slack
file host answering with a malformed Location therefore leaked the response stream.

Returning nil instead folds the case into the existing "Unexpected response" branch, which
closes the body.

* Cover remote attachments end to end, and say nothing when there is nothing to say

A message carrying only a file stored outside Slack had no route-level coverage: the unit
tests reached remote-file? and the history message, but nothing exercised the reply path
that replaced master's refused-files-message.

A file_share event may also carry an empty :files vector, which with blank text left
handle-message-file-share posting an empty Slack message. It now posts nothing.

* Name an unsupported file type, and stop suggesting a retry for failed uploads

Retrying a failed upload fails the same way. The 415 from create-csv-upload! is the one error safe and useful to
explain, so say the contents don't look like a CSV or TSV.

* Word the unsupported file type message to cover the filename too

create-csv-upload! also answers 415 for an extension it doesn't accept, such as data.CSV, so the message can't
blame the contents.

* Return only the upload history, and describe the size limit as the bot's own

* Show the upload module's own 4xx errors to the user

* Port the upload failure message cases from #81202 to upload-file!

* Count every attached file in the Slack upload counter, under what became of it

Size rejections, unsupported and remote files, and unavailable uploads were never counted, so the error label missed the most common outcomes. Also stop formatting the model id with %d in the success log, where a nil id would have turned an upload into a reported failure.

* Point the user at the server logs when an upload fails for an unexplained reason

Everything that reaches the generic message needs an admin to read the logs, as the #81202 wording said.
2026-09-17 16:20:55 +00:00
Andrei Simionescu
d37f1a9dd3 Record which provider served each LLM call (#82280) 2026-09-17 17:19:48 +01:00
Ryan Laurie
1a32584bb8 Improve App DB Test Sharding (#82538) 2026-09-17 10:02:46 -06:00
Chris Vandoodewaard
d3ce9de3cc Bump backend deps for open security alerts; define core-provided deps in one place (#82518)
* Bump backend deps for open Snyk alerts; define core-provided deps once

Security fixes:
- athena: netty 4.1.137.Final -> 4.1.138.Final (CVE-2026-89044, HTTP request
  smuggling in netty-codec-http). Stays on the 4.1 line because the Athena JDBC
  driver's async AWS SDK path is not binary-compatible with 4.2.x.
- snowflake: pin zstd-jni 1.5.7-16. snowflake-jdbc-thin 4.0.2 pulls 1.5.6-5,
  which has eight open out-of-bounds read alerts, all fixed in 1.5.7-14.

t-digest (CVE-2026-87822, CVE-2026-87962) is left alone deliberately: Snyk lists
no fixed version, everything from 3.1 up is affected, and it cannot be excluded
because kixi.stats.core requires kixi.stats.digest, which imports TDigest at the
namespace level. Both CVEs are in MergingDigest.fromBytes and need an
attacker-supplied serialized digest; we only ever build digests in-process and
never deserialize one, so we are not exposed. Those two alerts need a dismissal.

Per review feedback on #82261: driver uberjars are stripped of anything core
already ships (build.plugin-uberjar/prune-provided-libs!), so a driver
re-pinning a core-provided library has no effect on what loads, still lands the
artifact in that driver's generated pom.xml for Snyk to alert on, and adds
another place to bump. Replaced 22 such declarations across 7 drivers with
:exclusions on the transitive dep that drags them in, so the version is defined
only in the root deps.edn.

Verified by resolving every driver basis before and after and diffing the
effective shipped set (basis minus core-provided libs): only the two intended
security bumps move, every other driver is byte-for-byte identical. All 8
touched drivers build and pass verify-driver. Regenerated poms confirm all 15
core-provided artifacts are now declared in the root pom.xml and in zero driver
poms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update modules/drivers/clickhouse/deps.edn

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Exclude httpcore5 and bcutil-jdk18on to close scan-only version mismatches

Per review on #82518:

- httpcore5: databricks-jdbc-thin 3.4.1 declares 5.3.6 and libthrift 0.24.0
  declares 5.2, while the root deps.edn pins 5.4.3. Only httpcore5-h2 was
  excluded, so httpcore5 still landed in the generated databricks pom.xml --
  and excluding it from the driver alone would have left libthrift's 5.2 in the
  standalone POM for scanners to flag. Excluded from both, matching what
  clickhouse and hive-like already do.

- bcutil-jdk18on: databricks-jdbc-thin declares 1.84 and snowflake-jdbc-thin
  4.0.2 declares 1.82, but both resolve to 1.85 transitively via the root's
  bcpkix-jdk18on 1.85. Same scan-only mismatch as bcpkix/bcprov, which were
  already excluded; added bcutil alongside them in both drivers.

No change to what either driver ships -- these artifacts are core-provided and
already stripped from the driver uberjars by prune-provided-libs!.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-17 11:43:38 -04:00
technomancy
249390c4b6 Make mage start-db print a command to connect a client where known. (#82621)
Sometimes you want to start a DB and connect your client directly to
mess around, but you haven't memorized the exact command. Why not make
mage give you a lil hint?
2026-09-17 08:38:48 -07:00
Eric Normand
efbd247394 Ignore FKs from inactive tables when checking source replacement (#82618)
Sync deactivates a dropped table but leaves its fields active, so an FK
field on a dropped or archived table kept reporting
:incompatible-implicit-joins and blocked find and replace on the table
it targets. The incoming-FK check now requires the FK field's table to
be active too.

Fixes #82615
2026-09-17 15:11:05 +00:00
github-automation-metabase
bfc9d9aa91 Auto-update documentation for master (#82604)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-17 09:34:23 -04:00
Will Fry
8658d420d2 GDGT-3236 Move field sorting loader to end of row to avoid layout shift (#82645)
Move field sorting loader to end of row to avoid layout shift

The loader rendered before the sorting controls, pushing them right while
the field order request was in flight. Rendering it last keeps the
controls in place.

Fixes GDGT-3236

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:24:30 +00:00
github-automation-metabase
1a4afe2350 Tighten ratchets (#82655)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-17 13:14:41 +00:00
Mahatthana (Kelvin) Nomsawadi
d53a909a92 Revert "Integration branch for Embedding settings reorganization (#80336)" (#82570)
* Revert "Integration branch for Embedding settings reorganization (#80336)"

This reverts commit b9d217cbb4.

Also drop enable_embedding_modular from the closed stats snapshot
schema. #82447 (Require typed, closed argument schemas in mu/defn)
added that line after #80336, so git revert leaves it behind while
removing the key from the snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ

* fix: keep writing embedding_theme.is_default

The is_default migration stays after the revert, since it may already
have run and migrations cannot be removed, so the column still exists.

- Restore its serdes :skip entry and the theme API key-set
  expectations for endpoints that return the whole row.
- Keep marking seeded Light/Dark themes as default, so the flag stays
  meaningful when the embedding hub work is picked up again.
- Keep returning it from the theme API, including the list endpoint,
  as #80336 did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ

* refactor: keep the settings-components module

#80336 moved the settings page primitives into the shared
settings-components module. That move is not part of what is being
reverted, and later master work builds on it (#82389, #82462), so keep
it: restore the module, its lint entries, and point imports back at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ

* refactor: keep the setup guide rename

#79514 renamed the embedding hub checklist code to setup guide, which is
what the UI already calls it. It is a rename only: the API path, setting
names and persisted state keep their embedding-hub names. Re-apply it on
top of the revert so the name stays free for the embedding hub section
when that work is picked up again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014sqWaxii4kHFrYmW9c45zZ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:08:13 +01:00
Chris Vandoodewaard
9daae78940 stop set-milestone from exploding on first CI run on new release branch (#82576) 2026-09-17 08:33:54 -04:00
Stas Gavrylov
c881cfaa4e GDGT-2793: Stabilize Python incremental transform test (#82423)
* GDGT-2793: Stabilize Python incremental transform test

* GDGT-2793: Ground Python transform timeouts

* GDGT-2793: Address PR comments
2026-09-17 14:32:10 +03:00
979 changed files with 100051 additions and 71544 deletions

View File

@@ -84,6 +84,7 @@
:metabase/validate-defendpoint-route-uses-kebab-case {:level :warning}
:metabase/validate-defendpoint-query-params-use-kebab-case {:level :warning}
:metabase/validate-logging {:level :warning}
:metabase/validate-mb-app-db-migrations-test {:level :warning}
:metabase/validate-mb-driver-tests {:level :warning}
:metabase/validate-mb-once {:level :warning}
@@ -319,6 +320,12 @@
{metabase-enterprise.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.mirror/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/map->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
clojure.core/println {:message "Use metabase.util.log instead of clojure.core/println"}
clojure.core/printf {:message "Use metabase.util.log instead of clojure.core/printf"}
@@ -617,7 +624,7 @@
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
@@ -847,7 +854,13 @@
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world
potemkin/import-def macros.potemkin.namespaces/import-def
potemkin/import-fn macros.potemkin.namespaces/import-fn
potemkin/import-macro macros.potemkin.namespaces/import-macro
potemkin.namespaces/import-def macros.potemkin.namespaces/import-def
potemkin.namespaces/import-fn macros.potemkin.namespaces/import-fn
potemkin.namespaces/import-macro macros.potemkin.namespaces/import-macro}}
:config-in-comment
{:linters {:unresolved-symbol {:level :off}}}

View File

@@ -137,6 +137,7 @@
:uses #{actions
analytics
api
api-scope
api.eid-translation
collections
lib
@@ -317,7 +318,9 @@
api-scope
{:team "Metabot"
:api #{metabase.api-scope.core
metabase.api-scope.init}}
metabase.api-scope.data-app
metabase.api-scope.init}
:uses #{util}}
api.eid-translation
{:team "UX West"
@@ -548,6 +551,7 @@
query-processor.pivot
request
settings
startup
system
task
tiles
@@ -640,6 +644,7 @@
:ns-prefix "metabase.collections-rest"
:api #{metabase.collections-rest.api}
:uses #{api
api-scope
api.eid-translation
collections
documents
@@ -795,6 +800,7 @@
:uses #{actions
analytics
api
api-scope
api.eid-translation
app-db
channel
@@ -904,7 +910,6 @@
metabase.driver.settings
metabase.driver.sql
metabase.driver.sql-jdbc
metabase.driver.sql-jdbc.connection.pool-lock
metabase.driver.sql-jdbc.execute
metabase.driver.sql-jdbc.execute.diagnostic
metabase.driver.sql-jdbc.sync
@@ -930,6 +935,7 @@
query-processor.driver-api
settings
sql-tools
startup
system
tracing
util
@@ -957,28 +963,6 @@
:model-imports #{:model/Card :model/Dashboard}
:model-exports #{:model/EmbeddingTheme}}
embedding-hub
{:team "Embedding"
:api #{metabase.embedding-hub.api}
:uses #{api
appearance
audit-app
embedding
metabot
permissions
premium-features
util.config
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/ConnectionImpersonation
:model/Dashboard
:model/Database
:model/DatabaseRouter
:model/EmbeddingTheme
:model/Sandbox
:model/Table
:model/Tenant}}
embedding.rest
{:team "Embedding"
@@ -1021,7 +1005,7 @@
explorations
{:team "UX West"
:api #{metabase.explorations.core metabase.explorations.init metabase.explorations.schema}
:api #{metabase.explorations.api metabase.explorations.core metabase.explorations.init metabase.explorations.schema}
:uses #{analytics
analytics.interface
api
@@ -1101,7 +1085,7 @@
glossary
{:team "Gadget"
:api #{metabase.glossary.api}
:api #{metabase.glossary.api metabase.glossary.core}
:uses #{api
events
lib.schema
@@ -1126,6 +1110,7 @@
indexed-entities
{:team "UX West"
:api #{metabase.indexed-entities.api
metabase.indexed-entities.core
metabase.indexed-entities.init}
:uses #{analytics
api
@@ -1340,6 +1325,7 @@
comments
dashboards
documents
glossary
lib
lib.be
lib.metadata
@@ -1368,6 +1354,7 @@
util
util.config
warehouse-schema-overlay
warehouses
warehouses.schema}
:model-imports #{:model/Card
:model/Collection
@@ -1400,6 +1387,7 @@
mcp
models
request
server
settings
system
task
@@ -1582,6 +1570,7 @@
settings
setup
sql-tools
startup
sync
util
util.config
@@ -1616,10 +1605,7 @@
system
upload
util}
:model-imports #{:model/AuthIdentity
:model/Card
:model/Database
:model/MetabotMessage}}
:model-imports #{:model/AuthIdentity :model/Card :model/MetabotMessage}}
metrics
{:team "UX West"
@@ -1758,6 +1744,7 @@
{:team "Graphy"
:api #{metabase.native-query-snippets.api metabase.native-query-snippets.core metabase.native-query-snippets.schema}
:uses #{api
api-scope
collections
events
lib
@@ -2132,8 +2119,10 @@
:ns-prefix "metabase.queries-rest"
:api #{metabase.queries-rest.api}
:uses #{api
api-scope
api.eid-translation
collections
embedding
events
lib
lib.be
@@ -2216,6 +2205,7 @@
:uses #{analytics
analytics.interface
api
api-scope
app-db
appearance
audit-app
@@ -2338,7 +2328,8 @@
{:team "UX West"
:api #{metabase.revisions.api
metabase.revisions.core
metabase.revisions.init}
metabase.revisions.init
metabase.revisions.models.revision}
:uses #{api
collections
dashboards
@@ -2368,7 +2359,8 @@
:model/Measure
:model/ParameterCard
:model/Segment
:model/Transform}}
:model/Transform
:model/TransformTest}}
run-tracking
{:team "Gadget"
@@ -2410,6 +2402,7 @@
:uses #{analytics
analytics.interface
api
api-scope
app-db
appearance
audit-app
@@ -2491,6 +2484,7 @@
analytics.interface
api
api-keys
api-scope
app-db
appearance
core.initialization-status
@@ -2525,6 +2519,7 @@
metabase.session.schema
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
:uses #{api
api-scope
app-db
channel
events
@@ -2647,6 +2642,7 @@
server
settings
sso.auth-identity
startup
system
util
util.config}
@@ -2822,6 +2818,7 @@
premium-features
query-processor
settings
startup
util}
:model-imports #{:model/Card :model/Dashboard :model/DashboardCard}}
@@ -2831,6 +2828,7 @@
metabase.timeline.core}
:uses #{analytics
api
api-scope
collections
events
lib.schema
@@ -3067,6 +3065,7 @@
:api #{metabase.user-key-value.api
metabase.user-key-value.init}
:uses #{api
api-scope
lib.schema
util
util.config}}
@@ -3110,6 +3109,7 @@
:ns-prefix "metabase.users-rest"
:api #{metabase.users-rest.api}
:uses #{api
api-scope
appearance
collections
events
@@ -3210,6 +3210,7 @@
:api #{metabase.warehouses-rest.api}
:uses #{analytics
api
api-scope
app-db
collections
database-routing
@@ -3229,13 +3230,11 @@
sync
upload
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
warehouses.schema
warehouses.secrets
enterprise/permissions}
warehouses.secrets}
:model-imports #{:model/Card
:model/Collection
:model/Database
@@ -3278,11 +3277,8 @@
settings
sync.analyze
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
enterprise/permissions}
warehouses}
:model-exports #{:model/Dimension
:model/Field
:model/FieldUserSettings
@@ -3303,6 +3299,7 @@
:api #{metabase.warehouse-schema-rest.api}
:uses #{analytics
api
api-scope
app-db
collections
database-routing
@@ -3352,6 +3349,7 @@
collections
dashboards
driver
indexed-entities
lib
lib.be
lib.legacy-mbql
@@ -3376,7 +3374,6 @@
:model/Database
:model/Field
:model/ModelIndex
:model/ModelIndexValue
:model/Query
:model/Revision
:model/Segment
@@ -3605,6 +3602,7 @@
{:team "UX West"
:api #{metabase-enterprise.content-translation.routes}
:uses #{api
api-scope
content-translation
embedding
premium-features
@@ -3634,6 +3632,7 @@
metabase-enterprise.custom-viz-plugin.init
metabase-enterprise.custom-viz-plugin.settings}
:uses #{api
api-scope
events
models
premium-features
@@ -3647,6 +3646,7 @@
{:team "Embedding"
:api #{metabase-enterprise.data-apps.api metabase-enterprise.data-apps.init metabase-enterprise.data-apps.sync}
:uses #{api
api-scope
collections
lib
lib.be
@@ -3802,8 +3802,13 @@
enterprise/embedding
{:team "Embedding"
:ns-prefix "metabase-enterprise.embedding-hub"
:api #{}
:uses #{lib.schema
:api #{metabase-enterprise.embedding-hub.api}
:uses #{api
appearance
audit-app
embedding
lib.schema
permissions
premium-features
util
warehouse-schema-overlay
@@ -4032,6 +4037,7 @@
:ns-prefix "metabase-enterprise.sandbox"
:api #{metabase-enterprise.sandbox.api.routes metabase-enterprise.sandbox.api.util metabase-enterprise.sandbox.schema}
:uses #{api
api-scope
app-db
audit-app
driver
@@ -4114,6 +4120,7 @@
:model/Timeline
:model/Transform
:model/TransformTag
:model/TransformTest
:model/User}}
enterprise/search
@@ -4348,6 +4355,31 @@
:model-exports #{:model/Tenant}
:model-imports #{:model/Collection :model/User}}
enterprise/transform-testing
{:team "Graphy"
:api #{metabase-enterprise.transform-testing.api metabase-enterprise.transform-testing.init}
:uses #{api
audit-app
driver
events
lib
lib.schema
models
premium-features
revisions
run-tracking
sql-tools
sql-tools.parsing
task
transforms.base
util
warehouses}
:model-exports #{:model/TransformTest}
:model-imports #{:model/Collection
:model/Database
:model/Transform
:model/User}}
enterprise/transforms
{:team "Graphy"
:module-exports #{enterprise/transforms.inspector

View File

@@ -0,0 +1,21 @@
(ns macros.potemkin.namespaces)
(defn- import-var-expansion
"Expansion shared by all of the `import-*` replacements below. Synthetic \"docstring\" is needed to silence
`:missing-docstring` linter."
([sym]
(import-var-expansion sym (-> sym name symbol)))
([sym new-name]
`(def ~new-name "docstring" ~sym)))
(defmacro import-def
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))
(defmacro import-fn
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))
(defmacro import-macro
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))

View File

@@ -14,10 +14,10 @@
:consistent-alias 2
:def-fn 1
:deprecated-namespace 95
:deprecated-var 95
:deprecated-var 85
:discouraged-java-method 4
:discouraged-namespace 69
:discouraged-var 130
:discouraged-namespace 68
:discouraged-var 132
:duplicate-require 1
:equals-true 1
:main-without-gen-class 1
@@ -32,7 +32,7 @@
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:metabase/validate-deftest 9
:missing-docstring 12
:missing-docstring 4
:missing-protocol-method 3
:non-arg-vec-return-type-hint 1
:reduce-without-init 3

View File

@@ -1,8 +1,22 @@
(ns hooks.metabase.app-db.schema-migrations-test.impl
(:require [clj-kondo.hooks-api :as hooks]))
(:require
[clj-kondo.hooks-api :as hooks]))
(defn test-migrations
[{{[_ migration-range {[binding] :children} & body] :children} :node}]
(defn- validate-mb-app-db-migrations-test!
"CI runs the namespaces tagged `:mb/app-db-migrations-test` on a shard of their own, because a count-based split
can't balance them (see `.github/workflows/app-db.yml`). An untagged one still runs, just on a shard sized for
everything else."
[{:keys [ns node]}]
(when-not (:mb/app-db-migrations-test (meta ns))
(hooks/reg-finding!
(assoc (meta node)
:message (str "Namespaces that run migration tests (test-migrations) should be marked"
" ^:mb/app-db-migrations-test [:metabase/validate-mb-app-db-migrations-test]")
:type :metabase/validate-mb-app-db-migrations-test))))
(defn test-migrations!
[{{[_ migration-range {[binding] :children} & body] :children} :node, :as x}]
(validate-mb-app-db-migrations-test! x)
(let [node* (hooks/list-node
(list*
(hooks/token-node `let)

View File

@@ -0,0 +1,25 @@
(ns hooks.metabase.app-db.schema-migrations-test.impl-test
(:require
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.test :refer :all]
[hooks.metabase.app-db.schema-migrations-test.impl]))
(defn- finding-types!
[ns-symb]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/validate-mb-app-db-migrations-test {:level :warning}}}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
{:ns ns-symb
:node (hooks/parse-string (pr-str '(impl/test-migrations ["v64.2026-09-01T00:00:00"] [migrate!] (migrate!))))})
(mapv :type @(:findings clj-kondo.impl.utils/*ctx*))))
(deftest ^:synchronized require-app-db-migrations-test-tag-test
(testing "a namespace that runs migration tests must be tagged, so CI shards it with the other migration tests"
(is (= [:metabase/validate-mb-app-db-migrations-test]
(finding-types! 'metabase.some-migrations-test))))
(testing "a tagged namespace is fine"
(is (= []
(finding-types! (with-meta 'metabase.some-migrations-test {:mb/app-db-migrations-test true}))))))

View File

@@ -121,6 +121,9 @@ MB_EDITION="ee"
# Frontend dev server port (for hot reload). Must match the port used by `yarn build-hot`.
# MB_FRONTEND_DEV_PORT=8080
# Frontend dev server bind address. Defaults to 127.0.0.1; set 0.0.0.0 to reach it from other devices.
# MB_FRONTEND_DEV_HOST=127.0.0.1
# 3.2 E2E Local Runner Setup
# ------------------------------------------------------------------------------
@@ -161,3 +164,46 @@ TZ="US/Pacific"
# DO NOT USE Docker Desktop. Use Orbstack instead. Metabase will fail to connect
# to the dockerized Mongo QA database otherwise.
EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU=1
# ==============================================================================
# 4. BACKEND DRIVER TESTS
# ==============================================================================
#
# Connection details for running backend tests against a driver, e.g.
# ./bin/test-agent --drivers=snowflake :only '[metabase.foo-test]'
# 4.1 BigQuery
# ------------------------------------------------------------------------------
# Service account with BigQuery access in the project. The test user creates and drops the test-data datasets itself.
# https://cloud.google.com/iam/docs/keys-create-delete
# MB_BIGQUERY_CLOUD_SDK_TEST_PROJECT_ID="<project-id>"
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON='{"type": "service_account", ...}'
# Only for database routing tests
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING='{"type": "service_account", ...}'
# 4.2 Redshift
# ------------------------------------------------------------------------------
# Test data is loaded into a unique schema per test run.
# MB_REDSHIFT_TEST_HOST="<cluster>.<id>.<region>.redshift.amazonaws.com"
# Comma-separated hosts to pick one at random from, instead of MB_REDSHIFT_TEST_HOST
# MB_REDSHIFT_TEST_HOSTS=""
# MB_REDSHIFT_TEST_PORT=5439
# MB_REDSHIFT_TEST_DB="testdb"
# MB_REDSHIFT_TEST_USER="metabase_ci"
# MB_REDSHIFT_TEST_PASSWORD="<password>"
# Only for database routing tests
# MB_REDSHIFT_TEST_DB_ROUTING="dev"
# 4.3 Snowflake
# ------------------------------------------------------------------------------
# Key-pair authentication. The test user creates and drops the test-data databases itself.
# https://docs.snowflake.com/en/user-guide/key-pair-auth
# MB_SNOWFLAKE_TEST_ACCOUNT="<orgname>-<accountname>"
# MB_SNOWFLAKE_TEST_USER="<username>"
# MB_SNOWFLAKE_TEST_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# MB_SNOWFLAKE_TEST_WAREHOUSE="<warehouse>"
# MB_SNOWFLAKE_TEST_ADDITIONAL_OPTIONS=""

View File

@@ -85,17 +85,27 @@ runs:
git checkout ${{ github.sha }}
- name: Approve and Merge the PR
# Approving takes a different token than the one that opened the PR: GitHub won't let a token
# approve its own pull request.
- name: Approve the backport PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: |
gh pr review --approve "$PR_NUMBER"
gh pr merge --squash --auto "$PR_NUMBER"
run: gh pr review --approve "$PR_NUMBER"
shell: bash
env:
# uses a different token than the token that created the PR so we can approve and merge it
GH_TOKEN: ${{ inputs.github-token-2 }}
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
# Merging, though, has to use `github-token` rather than the `GITHUB_TOKEN` that approves: GitHub
# starts no workflow run for a push made with `GITHUB_TOKEN`, so an auto-merge enabled with it
# lands the backport on the release branch with no CI at all.
- name: Merge the backport PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: gh pr merge --squash --auto "$PR_NUMBER"
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
- uses: ./.github/actions/notify-pull-request
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'true' }}
with:

View File

@@ -64,8 +64,9 @@ jobs:
mariadb: ${{ steps.versions.outputs.mariadb }}
mysql: ${{ steps.versions.outputs.mysql }}
postgres: ${{ steps.versions.outputs.postgres }}
shards: ${{ steps.versions.outputs.shards }}
steps:
- name: Select versions to test
- name: Select versions and shards to test
id: versions
uses: actions/github-script@v9
env:
@@ -133,6 +134,32 @@ jobs:
core.setOutput(db, JSON.stringify(selected));
}
const commonExcludes = ":mb/driver-tests :mb/transforms-python-test :mb/old-migrations-test";
const shards = [
{
name: 'Enterprise Tests',
'build-static-viz': false,
'test-args': `:only '"enterprise/backend/test"' :exclude-tags '[${commonExcludes}]'`,
},
{
name: 'EE App DB Tests (Part 1)', // migrations shard
'build-static-viz': true,
'test-args': `:only '"test"' :only-tags '[:mb/app-db-migrations-test]' :exclude-tags '[${commonExcludes}]'`,
},
{
name: 'EE App DB Tests (Part 2)',
'build-static-viz': true,
'test-args': `:only '"test"' :partition/total 2 :partition/index 0 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
},
{
name: 'EE App DB Tests (Part 3)',
'build-static-viz': true,
'test-args': `:only '"test"' :partition/total 2 :partition/index 1 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
},
];
core.setOutput('shards', JSON.stringify(shards));
be-tests-mariadb:
needs: app-db-matrix
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mariadb') }}
@@ -142,26 +169,7 @@ jobs:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.mariadb) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
services:
mysql:
@@ -175,10 +183,6 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: mysql
MB_DB_TYPE: mysql
@@ -205,7 +209,8 @@ jobs:
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
# "be-tests-mariadb-10-6-ee (0)"
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
# Shared by every app DB job below via `*app-db-test-steps`.
steps: &app-db-test-steps
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }}
id: test-driver
@@ -214,9 +219,7 @@ jobs:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
test-args: ${{ matrix.job.test-args }}
be-tests-mysql:
needs: app-db-matrix
@@ -227,26 +230,7 @@ jobs:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.mysql) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
services:
mysql:
@@ -260,10 +244,6 @@ jobs:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: mb_test
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: mysql
MB_DB_TYPE: mysql
@@ -298,18 +278,7 @@ jobs:
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
# "be-tests-mariadb-10-6-ee (0)"
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
steps: *app-db-test-steps
be-tests-postgres:
needs: app-db-matrix
@@ -320,32 +289,9 @@ jobs:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.postgres) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: postgres
MB_DB_TYPE: postgres
@@ -374,18 +320,7 @@ jobs:
POSTGRES_USER: mb_test
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
id: test-driver
uses: ./.github/actions/test-driver
with:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
steps: *app-db-test-steps
app-db-tests-result:
needs:

View File

@@ -144,12 +144,20 @@ jobs:
EOF
)"
- name: Approve and Merge the PR
run: |
gh pr review --approve "$PR_NUMBER"
gh pr merge --squash --auto "$PR_NUMBER"
# Approving takes a different token than the one that opened the PR (above, the automation
# user's): GitHub won't let a token approve its own pull request.
- name: Approve the PR
run: gh pr review --approve "$PR_NUMBER"
shell: bash
env:
# uses a different token than the token that created the PR so we can approve and merge it
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }}
# Merging goes back through the automation user: GitHub starts no workflow run for a push made
# with `GITHUB_TOKEN`, so an auto-merge enabled with it lands on master with no CI.
- name: Merge the PR
run: gh pr merge --squash --auto "$PR_NUMBER"
shell: bash
env:
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }}

22
bb.edn
View File

@@ -5,7 +5,9 @@
;; Let Mage use the same module-resolution rules as the Kondo hooks.
:paths ["mage/src" "mage/test" "bin/lint-migrations-file/src" "dev/src" ".clj-kondo/src"]
;; use Git version of dep temporarily until a new release > 0.16.4 is cut
:deps {dev.weavejester/cljfmt {:git/sha "075eae26e3c8dad433b88f05927bcd50e3571328"
:deps {;; pinned to the same version as the backend's deps.edn; used by `mage clj-ts` to render HoneySQL as SQL
com.github.seancorfield/honeysql {:mvn/version "2.7.1437"}
dev.weavejester/cljfmt {:git/sha "075eae26e3c8dad433b88f05927bcd50e3571328"
:git/url "https://github.com/weavejester/cljfmt"} ;; run cljfmt from bb
io.github.paintparty/bling {:mvn/version "0.8.8"} ;; printing bells and whistles
medley/medley {:mvn/version "1.3.0"} ;; utilities
@@ -235,6 +237,24 @@
(nrepl-eval "metabase.comments.core" code (:port opts))
(nrepl-eval "metabase.comments.core" code)))}
clj-ts
{:doc "Browse a PR, your local changes, or any Clojure file translated into TypeScript-ish code for easier reading"
:examples [["./bin/mage clj-ts" "start the viewer; its home page shows your local changes (vs. the merge-base with master)"]
["./bin/mage clj-ts --pr 82600" "view a GitHub PR (a PR URL works too)"]
["./bin/mage clj-ts --branch my-feature" "view a branch compared to the branch it was created from"]
["./bin/mage clj-ts src/metabase/collections/models/collection.clj" "view one file"]
["./bin/mage clj-ts --print src/metabase/collections/models/collection.clj" "print one file's translation"]
["./bin/mage clj-ts --stats src" "translate every file under src and report fallbacks"]]
:options [["-p" "--pr PR" "GitHub PR number or URL to open"]
[nil "--branch BRANCH" "Branch to compare against its parent branch (or --base)"]
["-b" "--base REF" "Base ref for local changes or --branch (default: origin/master / the parent branch)"]
[nil "--port PORT" "Port for the local server" :default 7788 :parse-fn parse-long]
[nil "--print FILE" "Print the translation of FILE instead of starting the server"]
[nil "--stats" "Translate files under the given directories and report coverage"]]
:arg-schema [:sequential [:string {:name "file-or-dir" :description "A file to open (or directories for --stats)"}]]
:requires [[mage.clj-ts.cli :as clj-ts-cli]]
:task (task! (clj-ts-cli/run parsed))}
run-tests
{:doc "Quickly run tests locally. Pass dirs, files, namespaces, or single tests as arguments."
:examples [["./bin/mage run-tests" "launch interactive test picker"]

View File

@@ -27,7 +27,7 @@ services:
metabase:
image: ${METABASE_IMAGE:-metabase/metabase:latest}
ports:
- "${METABASE_PORT:-3000}:3000"
- "127.0.0.1:${METABASE_PORT:-3000}:3000"
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: metabase

View File

@@ -941,8 +941,10 @@
{:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"}}
:main-opts ["-m" "nrepl.cmdline" "-p" "50605"]}
;; - start a Socket REPL on port 50505 that you can connect your editor to:
:socket {:jvm-opts ["-Dclojure.server.repl={:address,\"0.0.0.0\",:port,50505,:accept,clojure.core.server/repl}"]}
;; - start a Socket REPL on port 50505 that you can connect your editor to. Bound to loopback: it has no
;; authentication, so exposing it on a network interface is remote code execution. dev/docker-compose.yml
;; overrides the address for the containerized dev app, where the published port is the boundary.
:socket {:jvm-opts ["-Dclojure.server.repl={:address,\"127.0.0.1\",:port,50505,:accept,clojure.core.server/repl}"]}
;; Liquibase CLI:
;;

View File

@@ -39,8 +39,8 @@ services:
image: clojure
container_name: metabase-app
ports:
- "3001:3000"
- "50505:50505"
- "127.0.0.1:3001:3000"
- "127.0.0.1:50505:50505"
working_dir: /dev/metabase
volumes:
- ../:/dev/metabase
@@ -58,7 +58,10 @@ services:
MB_POSTGRESQL_TEST_PASSWORD: password
MB_POSTGRESQL_TEST_PORT: 5432
MB_POSTGRESQL_TEST_USER: user
command: ["clojure", "-M:run:dev:drivers:drivers-dev:ee:ee-dev:socket"]
# The :socket alias binds the REPL to loopback inside the container, which docker's port
# forwarding can't reach, so bind it to all container interfaces here instead. The host
# side stays loopback-only via the 127.0.0.1: prefix in `ports` above.
command: ["clojure", "-J-Dclojure.server.repl={:address,\"0.0.0.0\",:port,50505,:accept,clojure.core.server/repl}", "-M:run:dev:drivers:drivers-dev:ee:ee-dev"]
profiles:
- dev-app
networks:
@@ -79,10 +82,14 @@ services:
# the browser loads the bundle from the host's :8080 — it must be
# published, not just exposed on the docker network.
ports:
- "8080:8080"
- "127.0.0.1:8080:8080"
volumes:
- ../:/dev/metabase
- ~/.bun/install/cache:/root/.bun/install/cache
environment:
# rspack binds to loopback by default; inside the container it must listen on all
# interfaces for docker's port forwarding to reach it.
MB_FRONTEND_DEV_HOST: 0.0.0.0
command:
- /bin/sh
- -c
@@ -99,7 +106,7 @@ services:
container_name: metabase-postgres-app-db
restart: always
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: mbuser
POSTGRES_PASSWORD: password
@@ -116,7 +123,7 @@ services:
container_name: metabase-mysql57-app-db
restart: always
ports:
- "3306:3306"
- "127.0.0.1:3306:3306"
environment:
MYSQL_USER: mbuser
MYSQL_PASSWORD: password
@@ -138,7 +145,7 @@ services:
image: metabase/qa-databases:postgres-sample-14
container_name: metabasedb_postgres_14_data
ports:
- "15432:5432"
- "127.0.0.1:15432:5432"
restart: always
profiles:
- postgres14data
@@ -149,7 +156,7 @@ services:
image: metabase/qa-databases:mysql-sample-5.7
container_name: metabasedb_mysql_5_7_data
ports:
- "23306:3306"
- "127.0.0.1:23306:3306"
restart: always
profiles:
- mysql57data
@@ -160,7 +167,7 @@ services:
image: metabase/qa-databases:mysql-sample-8
container_name: metabasedb_mysql_8_data
ports:
- "13306:3306"
- "127.0.0.1:13306:3306"
restart: always
profiles:
- mysql8data
@@ -172,7 +179,7 @@ services:
container_name: metabasedb_mongo_6_data
restart: always
ports:
- "27017:27017"
- "127.0.0.1:27017:27017"
profiles:
- mongodata
networks:
@@ -183,7 +190,7 @@ services:
container_name: vertica_data
restart: always
ports:
- "5433:5433"
- "127.0.0.1:5433:5433"
profiles:
- verticadata
networks:
@@ -195,8 +202,8 @@ services:
image: maildev/maildev:latest
container_name: maildev
ports:
- "1080:1080"
- "1025:1025"
- "127.0.0.1:1080:1080"
- "127.0.0.1:1025:1025"
profiles:
- pulse
networks:
@@ -209,7 +216,7 @@ services:
- ./idp-private-key.pem:/idp-private-key.pem
- ./idp-public-cert.pem:/idp-public-cert.pem
ports:
- "7000:7000"
- "127.0.0.1:7000:7000"
entrypoint:
- node
- app.js

View File

@@ -257,6 +257,38 @@ Credentials:
- **Region**. Pick one from the dropdown in **Admin > AI**. Defaults to `us-east-1`. You can also set it with the environment variable `MB_LLM_BEDROCK_REGION`.
- **Session token** (advanced). Only together with **Access key ID** and **Secret access key**. Only needed for temporary credentials. You can also set it with the environment variable `MB_LLM_BEDROCK_SESSION_TOKEN`.
### IAM permissions for Bedrock
Metabase talks to Bedrock through the mantle endpoint, `https://bedrock-mantle.{region}.api.aws`, not through `bedrock-runtime`. Mantle is a separate IAM namespace with its own actions, so a policy written against the `bedrock` prefix won't grant access. Metabase lists models and runs conversations, so it needs `bedrock-mantle:ListModels` and `bedrock-mantle:CreateInference`.
Here's a least-privilege policy that grants both:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["bedrock-mantle:ListModels", "bedrock-mantle:CreateInference"],
"Resource": "arn:aws:bedrock-mantle:*:*:project/*"
}
]
}
```
The AWS managed policy [AmazonBedrockMantleInferenceAccess](https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AmazonBedrockMantleInferenceAccess.html) also covers both actions (along with permissions Metabase doesn't use).
If Metabase reports "AWS Bedrock credentials lack permission for this model or action", check that your policy uses the `bedrock-mantle` prefix.
### The Bedrock models you can pick depend on the region
The table above lists the models Metabase can use. The **Models** card only offers the ones Bedrock serves in the connection's region, so what you can pick depends on the region. The mantle catalog has no cross-region inference profiles, so a model that isn't served in your region can't be reached from that region at all.
If the model list is empty or shorter than you expect after connecting:
- **Check the region**: the **Region** dropdown lists every AWS region, including regions where Bedrock serves none of these models. The [AWS model cards](https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html) show where each model is available. For example, the GPT models are only served in US regions.
- **Check your account's data retention setting**: Bedrock marks a model unavailable when your account's data retention mode doesn't meet what that model requires. For example, [Claude Fable 5](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-fable-5.html) requires the `aws_review` data retention mode.
## vLLM
- Provider key: `vllm`

View File

@@ -58,7 +58,7 @@ To connect a provider with your own credentials:
1. Go to **Admin > AI**.
2. Click **Add a provider**.
3. Pick your provider.
4. Fill in its credentials. The **Where do I find this?** links open the provider's key management pages in a new tab.
4. Enter your credentials. Most providers only need an **API key**. The **Where do I find this?** link opens your provider's key management page in a new tab. For what each provider needs, see [AI providers](./providers.md).
5. Click **Connect**.
If you've already copied a key, one neat thing: just paste the key anywhere on the provider grid, and Metabase selects the matching provider and fills in the key for you. Check to make sure the provider matches.

8759
docs/api.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
---
title: "IP allowlist"
version: latest
has_magic_breadcrumbs: true
show_category_breadcrumb: true
show_title_breadcrumb: true
category: "Cloud"
layout: new-docs
---
# IP allowlist
Use an IP allowlist to limit access to your Metabase instance to approved IP addresses. The allowlist applies to all access to your instance, so people outside the allowlist can't reach the API, embedded dashboards and questions, or public links.
IP allowlists are only available on Metabase Cloud [Pro or Enterprise](https://www.metabase.com/pricing) plans. To restrict access to a self-hosted Metabase, use your own firewall or load balancer, or consider [air-gapping](https://www.metabase.com/product/air-gapping).
The allowlist accepts IPv4 addresses and CIDR ranges. Metabase treats a bare IPv4 address as a `/32` range.
By default, restricting access by IP address is turned off.
To allow Metabase to reach your databases, see [IP addresses to whitelist](./ip-addresses-to-whitelist.md).
## Set up an IP allowlist
When you save an allowlist, your instance restarts. Metabase blocks requests from addresses outside the allowlist, so include the address or range you're connecting from before you save.
The allowlist doesn't apply to the Metabase Store. If you leave out your own address, you can still log in to the Store and update the allowlist.
1. Log in to your Metabase [Store account](https://store.metabase.com).
2. Navigate to **Instances**.
3. In the instance you want to restrict access to, click **Settings**.
4. Scroll to **IP allowlist**.
5. Enable the **Restrict access by IP address** toggle.
6. In the **IP/CIDR ranges** field, enter an IPv4 address or a CIDR range.
7. To add more addresses, enter them in the empty field. To add several at once, paste a list with one address or range per line.
8. Click **Save**.
9. Review the warning, then confirm. Your instance restarts.
Metabase validates each entry as you type. To save the allowlist, you must first correct or remove invalid entries.
To remove an address, click the trash icon next to the address or range, then click **Save**.
## Turn off the IP allowlist
When you turn off the IP allowlist and save, Metabase clears the saved addresses. To restrict access again, you must re-enter the addresses.
Turning off the allowlist doesn't remove authentication. People still need to sign in to your Metabase.
In your instance's **IP allowlist** settings:
1. Disable the **Restrict access by IP address** toggle.
2. Click **Save**.
## Further reading
- [IP addresses to whitelist](./ip-addresses-to-whitelist.md)
- [Changing your domain name](./custom-domain.md)

View File

@@ -29,6 +29,7 @@ Manage your Metabase Cloud instance, subscription, and billing via the [Metabase
- [Changing which region your Metabase is hosted in](./change-region.md)
- [IP addresses to whitelist](ip-addresses-to-whitelist.md)
- [IP allowlist](./ip-allowlist.md)
- [Changing your domain name](./custom-domain.md)
### Migrations

View File

@@ -147,7 +147,9 @@ config:
embedding-homepage: hidden
embedding-secret-key: null
enable-embedding-interactive: false
enable-embedding-modular: false
enable-embedding-sdk: false
enable-embedding-simple: false
enable-embedding-static: false
enable-password-login: true
enable-pivoted-exports: true
enable-public-sharing: true
@@ -186,7 +188,7 @@ config:
landing-page-illustration: default
landing-page-illustration-custom: null
ldap-attribute-email: mail
ldap-attribute-firstname: givenName
ldap-attribute-firstname: givenname
ldap-attribute-lastname: sn
ldap-bind-dn: null
ldap-enabled: false

View File

@@ -731,17 +731,7 @@ Allow admins to securely embed questions and dashboards within other application
Allow admins to embed Metabase via interactive embedding?
### `MB_ENABLE_EMBEDDING_MODULAR`
- Type: boolean
- Default: `false`
- [Configuration file name](./config-file.md): `enable-embedding-modular`
Allow admins to embed Metabase via modular embedding, the SDK for React, and guest embeds?
### `MB_ENABLE_EMBEDDING_SDK [DEPRECATED]`
> DEPRECATED: 0.65.0
### `MB_ENABLE_EMBEDDING_SDK`
- Type: boolean
- Default: `false`
@@ -749,9 +739,7 @@ Allow admins to embed Metabase via modular embedding, the SDK for React, and gue
Allow admins to embed Metabase via the SDK?
### `MB_ENABLE_EMBEDDING_SIMPLE [DEPRECATED]`
> DEPRECATED: 0.65.0
### `MB_ENABLE_EMBEDDING_SIMPLE`
- Type: boolean
- Default: `false`
@@ -759,9 +747,7 @@ Allow admins to embed Metabase via the SDK?
Allow admins to embed Metabase via modular embedding?
### `MB_ENABLE_EMBEDDING_STATIC [DEPRECATED]`
> DEPRECATED: 0.65.0
### `MB_ENABLE_EMBEDDING_STATIC`
- Type: boolean
- Default: `false`
@@ -1165,7 +1151,7 @@ Attribute to use for the user's email. (usually 'mail', 'email' or 'userPrincipa
### `MB_LDAP_ATTRIBUTE_FIRSTNAME`
- Type: string
- Default: `givenName`
- Default: `givenname`
- [Configuration file name](./config-file.md): `ldap-attribute-firstname`
Attribute to use for the user's first name. (usually 'givenName').
@@ -1433,7 +1419,7 @@ Backed by the bedrock connection in the admin AI settings provider list: reads a
The AWS region for Amazon Bedrock (e.g. us-east-1).
Backed by the bedrock connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection. On a self-hosted Metabase, setting only the region enables Bedrock with the AWS default credentials chain, with no access keys configured.
Backed by the bedrock connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection. On a self-hosted Metabase, setting only the region enables Bedrock with the AWS default credentials chain, with no access keys configured. [The Bedrock models you can pick depend on the region](../ai/providers.md#the-bedrock-models-you-can-pick-depend-on-the-region).
### `MB_LLM_BEDROCK_SECRET_ACCESS_KEY`

View File

@@ -343,11 +343,12 @@ To configure a dashboard using its URL, you can add the following optional keywo
- `fullscreen`
- `refresh`
- `theme`
Here's an example URL:
```
https://metabase.mydomain.com/dash/2#refresh=60&fullscreen&night
https://metabase.mydomain.com/dash/2#refresh=60&fullscreen&theme=night
```
The part that says `refresh=60` sets the dashboard to automatically refresh every 60 seconds, `fullscreen` sets it to fullscreen mode. Use an ampersand, `&`, in between keywords, and make sure there's a hash, `#`, after the dashboard's ID number.

View File

@@ -33,7 +33,7 @@ Once Docker is installed, you’re ready to go.
2. Copy and paste this command, switching out `<branch-name>` for the name of the branch you’d like to test:
```bash
docker run --platform linux/amd64 -d -p 3000:3000 --name metabase-dev metabase/metabase-dev:<branch-name>
docker run --platform linux/amd64 -d -p 127.0.0.1:3000:3000 --name metabase-dev metabase/metabase-dev:<branch-name>
```
3. In your browser, navigate to `http://localhost:3000`, where you should see Metabase. It may take a minute or two to start up depending on your computer.
@@ -51,7 +51,7 @@ docker pull metabase/metabase-enterprise-head:latest
Then:
```
docker run --platform linux/amd64 -d -p 3000:3000 --name metabase metabase/metabase-enterprise-head:latest
docker run --platform linux/amd64 -d -p 127.0.0.1:3000:3000 --name metabase metabase/metabase-enterprise-head:latest
```
The “latest” tag is not automatically upgraded on your local machine, so the above commands ensure that you’re pulling the latest changes.

View File

@@ -6,6 +6,20 @@ title: Driver interface changelog
## Metabase 0.64.0
- `metabase.driver.sql.normalize/default-schema` now takes the database as well as the driver:
`[driver database]`. The schema an unqualified table reference resolves to is a property of the connection for a
driver that opens a database where others have a default schema — ClickHouse now answers with the database its
connection opened, where before it inherited `"public"` from `:sql`. `database` may be nil when the caller has none
in hand, and such a driver answers nil for it.
- New feature `:transforms/testing` -- whether the driver can run transform test suites against temp tables. Drivers
with this feature implement the new multimethods `metabase.driver/temp-table-name` `[driver]`,
`metabase.driver/compile-create-temp-table` `[driver {:keys [table query]}]`,
`metabase.driver/compile-drop-temp-table` `[driver table]`,
`metabase.driver/do-with-test-connection` `[driver database f]`,
`metabase.driver/execute-on-connection!` `[driver conn query]` and
`metabase.driver/query-on-connection` `[driver conn query {:keys [max-rows]}]`.
- `metabase.driver.sql-jdbc.execute/cancelation-poisons-connection?` `[driver]` -- whether canceling a `Statement`
leaves the `Connection` unfit for the next query, so that it must be discarded rather than returned to the
connection pool. The query processor cancels a `Statement` whenever reduction stops before the `ResultSet` runs out

View File

@@ -171,7 +171,7 @@ We have a few helpers for dealing with tests involving snowplow
Some of our tests depend on the email being set up, and require a local SMTP server. We use `maildev` Docker image for that purpose. At the time of this writing the image we use is `maildev/maildev:2.1.0`. The default cypress configuration for local development will handle this for you. If you want to set it up manually, you can use this command:
```sh
docker run -d -p 1180:1080 -p 1125:1025 maildev/maildev:2.2.1
docker run -d -p 127.0.0.1:1180:1080 -p 127.0.0.1:1125:1025 maildev/maildev:2.2.1
```
The e2e maildev is published on host ports 1180 (web UI) and 1125 (SMTP) on purpose. These differ, on purpose, from the 1080/1025 used for development.

View File

@@ -0,0 +1,11 @@
---
title: DataAppLink
layout: docs-api
redirect_from:
- /docs/latest/embedding/api/DataAppLink
---
<!DOCTYPE html><html class="default" lang="en" data-base="./"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>DataAppLink | Embedded analytics SDK API</title><link rel="icon" href="assets/favicon.ico"/>{% include docs/embedded-analytics-sdk-metadata.html %}<meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="assets/style.css"/><link rel="stylesheet" href="assets/highlight.css"/><link rel="stylesheet" href="assets/custom.css"/><script defer src="assets/main.js"></script><script defer src="assets/custom.js"></script><script async src="assets/icons.js" id="tsd-icons-script"></script><script async src="assets/search.js" id="tsd-search-script"></script><script async src="assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="index.html" class="title">Embedded analytics SDK API</a><div id="tsd-toolbar-links"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })">Back to documentation</a></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">DataAppLink</a></li></ul><h1>Variable DataAppLink<code class="tsd-tag">Const</code></h1></div><div class="tsd-signature"><span class="tsd-kind-variable">DataAppLink</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><br/>    <span class="tsd-kind-parameter">{ to, children, onClick, target, rel, ...rest }</span><span class="tsd-signature-symbol">:</span> <a href="DataAppLinkProps.html" class="tsd-signature-type tsd-kind-interface">DataAppLinkProps</a><span class="tsd-signature-symbol">,</span><br/><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=&gt;</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></div><div class="tsd-comment tsd-typography"><p>Internal-only navigation link inside a data app.</p>
<p>Renders a plain anchor rather than a router <code>&lt;Link&gt;</code>: a data app is mounted
outside the app's route tree, so it has no router context to read.</p>
</div><div class="tsd-type-declaration"><h4>Type Declaration</h4><ul class="tsd-parameters"><li class="tsd-parameter-signature"><ul class="tsd-signatures"><li class="tsd-signature" id="__type"><span class="tsd-signature-symbol">(</span><br/>    <span class="tsd-kind-parameter">{ to, children, onClick, target, rel, ...rest }</span><span class="tsd-signature-symbol">:</span> <a href="DataAppLinkProps.html" class="tsd-signature-type tsd-kind-interface">DataAppLinkProps</a><span class="tsd-signature-symbol">,</span><br/><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></li><li class="tsd-description"><div class="tsd-parameters"><h4 class="tsd-parameters-title">Parameters</h4><ul class="tsd-parameter-list"><li><span><span class="tsd-kind-parameter">{ to, children, onClick, target, rel, ...rest }</span>: <a href="DataAppLinkProps.html" class="tsd-signature-type tsd-kind-interface">DataAppLinkProps</a></span></li></ul></div><h4 class="tsd-returns-title">Returns <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></h4></li></ul></li></ul></div></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div></div><div class="site-menu"><nav id="tsd-sidebar-links" class="tsd-navigation"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })" class="tsd-nav-link">Back to documentation</a></nav><nav class="tsd-navigation"><a href="index.html">Embedded analytics SDK API</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer><p><script type="text/javascript" src="/gdpr-cookie-notice/dist/script.js"></script><script type="text/javascript" src="/js/cookie-consent.js"></script><link href="/gdpr-cookie-notice/dist/style.css" rel="stylesheet" /><link href="/css/gdpr.css" rel="stylesheet" /></p></footer><div class="overlay"></div></body></html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
---
title: DataAppRouter
layout: docs-api
redirect_from:
- /docs/latest/embedding/api/DataAppRouter
---
<!DOCTYPE html><html class="default" lang="en" data-base="./"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>DataAppRouter | Embedded analytics SDK API</title><link rel="icon" href="assets/favicon.ico"/>{% include docs/embedded-analytics-sdk-metadata.html %}<meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="assets/style.css"/><link rel="stylesheet" href="assets/highlight.css"/><link rel="stylesheet" href="assets/custom.css"/><script defer src="assets/main.js"></script><script defer src="assets/custom.js"></script><script async src="assets/icons.js" id="tsd-icons-script"></script><script async src="assets/search.js" id="tsd-search-script"></script><script async src="assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="index.html" class="title">Embedded analytics SDK API</a><div id="tsd-toolbar-links"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })">Back to documentation</a></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">DataAppRouter</a></li></ul><h1>Variable DataAppRouter<code class="tsd-tag">Const</code></h1></div><div class="tsd-signature"><span class="tsd-kind-variable">DataAppRouter</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">{ children }</span><span class="tsd-signature-symbol">:</span> <a href="DataAppRouterProps.html" class="tsd-signature-type tsd-kind-interface">DataAppRouterProps</a><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=&gt;</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></div><div class="tsd-comment tsd-typography"><p>Wrap your data-app tree once. Inside, use <code>&lt;DataAppLink to=&quot;…&quot;&gt;</code> for
navigation and <code>useDataAppLocation()</code> to read the current path.</p>
<p>No <code>basename</code> prop: it is auto-detected from the iframe URL
(<code>/embed/apps/&lt;name&gt;</code>). In the dev preview, where there is no prefix, the
basename resolves to <code>&quot;&quot;</code> and the sub-path is just the raw pathname.</p>
</div><div class="tsd-type-declaration"><h4>Type Declaration</h4><ul class="tsd-parameters"><li class="tsd-parameter-signature"><ul class="tsd-signatures"><li class="tsd-signature" id="__type"><span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">{ children }</span><span class="tsd-signature-symbol">:</span> <a href="DataAppRouterProps.html" class="tsd-signature-type tsd-kind-interface">DataAppRouterProps</a><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></li><li class="tsd-description"><div class="tsd-parameters"><h4 class="tsd-parameters-title">Parameters</h4><ul class="tsd-parameter-list"><li><span><span class="tsd-kind-parameter">{ children }</span>: <a href="DataAppRouterProps.html" class="tsd-signature-type tsd-kind-interface">DataAppRouterProps</a></span></li></ul></div><h4 class="tsd-returns-title">Returns <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6" class="tsd-signature-type external" target="_blank">JSX_3.Element</a></h4></li></ul></li></ul></div></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div></div><div class="site-menu"><nav id="tsd-sidebar-links" class="tsd-navigation"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })" class="tsd-nav-link">Back to documentation</a></nav><nav class="tsd-navigation"><a href="index.html">Embedded analytics SDK API</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer><p><script type="text/javascript" src="/gdpr-cookie-notice/dist/script.js"></script><script type="text/javascript" src="/js/cookie-consent.js"></script><link href="/gdpr-cookie-notice/dist/style.css" rel="stylesheet" /><link href="/css/gdpr.css" rel="stylesheet" /></p></footer><div class="overlay"></div></body></html>

View File

@@ -0,0 +1,9 @@
---
title: DataAppRouterProps
layout: docs-api
redirect_from:
- /docs/latest/embedding/api/DataAppRouterProps
---
<!DOCTYPE html><html class="default" lang="en" data-base="./"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>DataAppRouterProps | Embedded analytics SDK API</title><link rel="icon" href="assets/favicon.ico"/>{% include docs/embedded-analytics-sdk-metadata.html %}<meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="assets/style.css"/><link rel="stylesheet" href="assets/highlight.css"/><link rel="stylesheet" href="assets/custom.css"/><script defer src="assets/main.js"></script><script defer src="assets/custom.js"></script><script async src="assets/icons.js" id="tsd-icons-script"></script><script async src="assets/search.js" id="tsd-search-script"></script><script async src="assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="index.html" class="title">Embedded analytics SDK API</a><div id="tsd-toolbar-links"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })">Back to documentation</a></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">DataAppRouterProps</a></li></ul><h1>Interface DataAppRouterProps</h1></div><div class="tsd-signature"><span class="tsd-signature-keyword">interface</span> <span class="tsd-kind-interface">DataAppRouterProps</span> <span class="tsd-signature-symbol">{</span><br/>    <a class="tsd-kind-property" href="#children">children</a><span class="tsd-signature-symbol">?:</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/index.d.ts#L478" class="tsd-signature-type external" target="_blank">ReactNode</a><span class="tsd-signature-symbol">;</span><br/><span class="tsd-signature-symbol">}</span></div><section class="tsd-panel-group tsd-index-group"><section class="tsd-panel tsd-index-panel"><details class="tsd-index-content tsd-accordion" open><summary class="tsd-accordion-summary tsd-index-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h5 class="tsd-index-heading uppercase">Index</h5></summary><div class="tsd-accordion-details"><section class="tsd-index-section"><h3 class="tsd-index-heading">Properties</h3><div class="tsd-index-list"><a href="#children" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>children?</span></a>
</div></section></div></details></section></section><details class="tsd-panel-group tsd-member-group tsd-accordion" open><summary class="tsd-accordion-summary" data-key="section-Properties"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h2>Properties</h2></summary><section><section class="tsd-panel tsd-member"><h3 class="tsd-anchor-link" id="children"><code class="tsd-tag">Optional</code><span>children</span><a href="#children" aria-label="Permalink" class="tsd-anchor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="assets/icons.svg#icon-anchor"></use></svg></a></h3><div class="tsd-signature"><span class="tsd-kind-property">children</span><span class="tsd-signature-symbol">?:</span> <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/index.d.ts#L478" class="tsd-signature-type external" target="_blank">ReactNode</a></div></section></section></details></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div><details open class="tsd-accordion tsd-page-navigation"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>On This Page</h3></summary><div class="tsd-accordion-details"><details open class="tsd-accordion tsd-page-navigation-section"><summary class="tsd-accordion-summary" data-key="section-Properties"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg>Properties</summary><div><a href="#children"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>children</span></a></div></details></div></details></div><div class="site-menu"><nav id="tsd-sidebar-links" class="tsd-navigation"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })" class="tsd-nav-link">Back to documentation</a></nav><nav class="tsd-navigation"><a href="index.html">Embedded analytics SDK API</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer><p><script type="text/javascript" src="/gdpr-cookie-notice/dist/script.js"></script><script type="text/javascript" src="/js/cookie-consent.js"></script><link href="/gdpr-cookie-notice/dist/style.css" rel="stylesheet" /><link href="/css/gdpr.css" rel="stylesheet" /></p></footer><div class="overlay"></div></body></html>

View File

@@ -0,0 +1,10 @@
---
title: UseDataAppLocationResult
layout: docs-api
redirect_from:
- /docs/latest/embedding/api/UseDataAppLocationResult
---
<!DOCTYPE html><html class="default" lang="en" data-base="./"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>UseDataAppLocationResult | Embedded analytics SDK API</title><link rel="icon" href="assets/favicon.ico"/>{% include docs/embedded-analytics-sdk-metadata.html %}<meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="assets/style.css"/><link rel="stylesheet" href="assets/highlight.css"/><link rel="stylesheet" href="assets/custom.css"/><script defer src="assets/main.js"></script><script defer src="assets/custom.js"></script><script async src="assets/icons.js" id="tsd-icons-script"></script><script async src="assets/search.js" id="tsd-search-script"></script><script async src="assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="index.html" class="title">Embedded analytics SDK API</a><div id="tsd-toolbar-links"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })">Back to documentation</a></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">UseDataAppLocationResult</a></li></ul><h1>Type Alias UseDataAppLocationResult</h1></div><div class="tsd-signature"><span class="tsd-signature-keyword">type</span> <span class="tsd-kind-type-alias">UseDataAppLocationResult</span> <span class="tsd-signature-symbol">=</span> <span class="tsd-signature-symbol">{</span><br/>    <a class="tsd-kind-property" href="#navigate">navigate</a><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">to</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=&gt;</span> <span class="tsd-signature-type">void</span><span class="tsd-signature-symbol">;</span><br/>    <a class="tsd-kind-property" href="#pathname">pathname</a><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span><span class="tsd-signature-symbol">;</span><br/><span class="tsd-signature-symbol">}</span></div><section class="tsd-panel-group tsd-index-group"><section class="tsd-panel tsd-index-panel"><details class="tsd-index-content tsd-accordion" open><summary class="tsd-accordion-summary tsd-index-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h5 class="tsd-index-heading uppercase">Index</h5></summary><div class="tsd-accordion-details"><section class="tsd-index-section"><h3 class="tsd-index-heading">Properties</h3><div class="tsd-index-list"><a href="#navigate" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>navigate</span></a>
<a href="#pathname" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>pathname</span></a>
</div></section></div></details></section></section><details class="tsd-panel-group tsd-member-group tsd-accordion" open><summary class="tsd-accordion-summary" data-key="section-Properties"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h2>Properties</h2></summary><section><section class="tsd-panel tsd-member"><h3 class="tsd-anchor-link" id="navigate"><span>navigate</span><a href="#navigate" aria-label="Permalink" class="tsd-anchor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="assets/icons.svg#icon-anchor"></use></svg></a></h3><div class="tsd-signature"><span class="tsd-kind-property">navigate</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">to</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span><span class="tsd-signature-symbol">)</span> <span class="tsd-signature-symbol">=&gt;</span> <span class="tsd-signature-type">void</span></div></section><section class="tsd-panel tsd-member"><h3 class="tsd-anchor-link" id="pathname"><span>pathname</span><a href="#pathname" aria-label="Permalink" class="tsd-anchor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="assets/icons.svg#icon-anchor"></use></svg></a></h3><div class="tsd-signature"><span class="tsd-kind-property">pathname</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span></div></section></section></details></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div><details open class="tsd-accordion tsd-page-navigation"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>On This Page</h3></summary><div class="tsd-accordion-details"><details open class="tsd-accordion tsd-page-navigation-section"><summary class="tsd-accordion-summary" data-key="section-Properties"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg>Properties</summary><div><a href="#navigate"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>navigate</span></a><a href="#pathname"><svg class="tsd-kind-icon" viewBox="0 0 24 24" aria-label="Property"><use href="assets/icons.svg#icon-1024"></use></svg><span>pathname</span></a></div></details></div></details></div><div class="site-menu"><nav id="tsd-sidebar-links" class="tsd-navigation"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })" class="tsd-nav-link">Back to documentation</a></nav><nav class="tsd-navigation"><a href="index.html">Embedded analytics SDK API</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer><p><script type="text/javascript" src="/gdpr-cookie-notice/dist/script.js"></script><script type="text/javascript" src="/js/cookie-consent.js"></script><link href="/gdpr-cookie-notice/dist/style.css" rel="stylesheet" /><link href="/css/gdpr.css" rel="stylesheet" /></p></footer><div class="overlay"></div></body></html>

View File

@@ -1 +1 @@
window.navigationData = "eJylml1z3CYUhv/LXns6bdqkU985tje1k7Qbr51cZHLBSicrugjWCHnjdPLfO0i7EoLDl3plD+/LcwAhdID9/O9CwTe1OF9cCsagUFTw11IcGpCLs0VRUVZK4Ivzz0HfnqgKk36qVM0WZ4sd5eXi/NVvP878mJUU+ybA6vQp8MXLVz++mEwJRMEVaaqNILJ8L0rCfL3ArafgiBrqC2K3u+OzpPToQwuNHolgXwzTJOap3G7/2aJgpGkW54sS9hIKoqBceEOj3ZmI4Y4MPff04bqkimwYmL5jLEcKPIkbrkCSQtEnjISpAdhaEUULhGMJAYTTdmsccd0dynAPLajXEuJafbKQmBqirYgkNSiQlxXhW1iRZyaIMYK4bhF//uP3X16+8FPXopUFeKG9jDO/4CMaedFwpzvqvlfOfB8kZey+kqLdVp53zGtJnB6nOpcVker+eQ9XUuxLcbDjJFfJjKvntpCvW6WcrqW4Z0VLjTMnQpw9h3r6uwalKN826Q8pWHNmKz7SpiWMfidpHfZXy4x/Bw2o1KlimzNjrcn4/1LIOh4QrZEZ9Z4qBvFQoy24WJc7z6y0lcxWvibFLvUxWN7Y4o0RJJCdaFX6nEdrzIg8LHBr0FlmysKBV5kR+0ocuP7afaLlFjL6Hqj3v1uRG31u1CVlCmR6nxH/7Kip0eZGsdfj/BV8bmS9PqW+tZZ3TrS2romk3zPyCbxKLPb6kcXySK8ln21nkz5HPKF8D4psSKOX8SdaejfSJXylHE7mi1ZVl4J/pduxCT5HIK9EYh9htpIBsR4wKoc+NKE+xnrnPjq3xieqqos9fQvPIe7omhfh9qBi+NuDmsdek5rF4NqTSr9p3uhXLzTgjiVxYgsV2SW5LjPyKMQm4OjE5p+lhk8f+u1rpN2OabL3TWj11IjuoDPafF9BTfk2NMikget6A2VJ+Vbb4eML9znbjtCL2uU4l4IJaZLM0tQJ2NnRF/KkpJKm7Y/1Lz6Jhaq8S3LbwEUxffpD0ZT8ayzBFPVecOAqklQOtpT1sxs6ZCHuy1MInpfSUFIonieBPQDkjCk0RD5HiIpmPhkpzji9sSkfrW2fU7+jja7Z1jx0mm24ohHaRol6OHu71MfHwNsbBbURwG+K8SeVeo7LD5iy+LqSh6qlbFbflGsGNXAVAE98KVHwYbaVGGlYnK6IIita7MyMDBGTeddcUfWst6MIbxSjvMHZ733ewrN5Oo2oMeKSAivXRQU1GUFGYXTDUQj+FzGXl1NJfKtCFR1OofRK0k4WX1eNEd+JLXVARmHyx3Bye2CWJhMYLXb2pwkRZ/BWrN1S3vjSRJ8zIxE4rnzod+uo5dOm7yWup1OP3xnPh22qp1KRiyNHymZFnhbuS4+iyEpQrv7e/AMFstuxDNkp3Cn3C+apoymVvwRVVHfwqNOGe7EDvkQmG2JK5guulqSmDElnRy2V9oaJDWGRB4mYUvkR8izmhxbks29WGGIqr3uRVlIf5HvyyF5M5T002FHHg/MTBA9BqIstcNVlfe+hacjWXgUQRw45CJ3Bu4dvYaZhSOTqwQphLT35rvYjYS0Y309LiHKkUFAoKN0dpSPFWN1E1WvYyBiKkuoaN1AtUxZlKsZ4d+LQDcAIOZVEjy67PKrfPBg7J6M0jXBLnsi6kHSvpmkkpqYR78TBxtyJQ7RuecwvrvRxJ7UOX1wxmXdTIpyb+NHwYOX7VmEIXZ5AOW71jJxk2iBET6B6cZmcIVXos/xVu2G0cG8T/bacGFZTjfIcyjtByuunyUYPlROYfW+sZp0Kk+t3eQSC6MpTKFIKOWSXU9BEyma5DxLRU6h6PP8kvGQgnZQC1xOow48xglPP68qIYD3hsTiDgV3do3ICU39FrTb1RTkXVvYn1dViNB1T398DL28P6g6aveCN8Q3A9fjhqnmAih2wPpx0+wtqCaHzvt53/Q2KVkE3q0eMq8VGoq/xVqsWRZel1e7bvNQ/X2I7GzMRM3n9zyy9xF7OZF4BgwCzlzOZ2PBNxEze+pF5cevH6C2YVeNhX4ZGsZejTP6Mz1xLSHtH9ntGi+4k6njW5buNcIzGtcRUcy6m7JhPhDL9W1O9Y20CIW2fEXEixQJaP3m+2FN/UNQ7BnblwDWcZbYXS1QOX8XpFrRSAlcP/t/FOyaj+WN5bNDMq9/h5NETDvWOUV05KbhQkYhCuWGECjyQo6PbO+NXuaOUuFntlnbfTtUUE3k+VCLlYRgG5Ls2UXzLw5f/APtqHqE="
window.navigationData = "eJylm8ty2zYUht9Fa0+nTZt06p3jS2rHaRXJchaZLGDyREIFAjQIWlE6efcORIkEgYMbu0oG/4/vACAIHgDy539nCr6p2fnsUjAGhaKCv5Vi14Ccnc2KDWWlBD47/xz01URtMOmnjarY7Gy2pbycnb/57ceZHzOXom4CrIM+Br56/ebHF5MpgSi4Is3mSRBZfhAlYb5e4NZTcEQN9QWx293xWVJ69LGFRo9EsC+GaRTzVG63/2xWMNI0s/NZCbWEgigoZ97QaHdGYrgjfc89fbguqSJPDEzfMZYjBZ7ELVcgSaHoC0bC1ABsqYiiBcKxhADCabs1jrjuDmW4hxbUawlxrT5ZSEwN0eZEkgoUyMsN4WuYkz0TxBhBXLeIP//x+y+vX/mpS9HKArzQTsaZX/ARjbxouNMddd8rZ74PkjL2sJGiXW8875jXkjg9TnUuN0Sqh30NV1LUpdjZcZKrZMbVc1vIt61STtdS3JOipcaZEiHOnkI9/bsEpShfN+kPKVhzYiseadMSRr+TtA77q2XGX0ADKnWq2ObMWEsy/P9GyCoeEK2RGfWBKgbxUIMtuFiXW8+stJXMVr4lxTb1MVje2OKNESSQrWhV+pxHa0yI3C9wS9BZZsrCgVeZEPtK7Lj+2n2i5Roy+h6o979bkRt9atQbyhTI9D4j/slRU6NNjWKvx/kr+NTIen1KfWst75RobVURSb9n5BN4lVjs5TOL5ZFeSz7bziZ9jnhC+QEUeSKNXsZfaOndSJfwlXI4mS9atbkU/CtdD03wOQJ5JRL7CLOVDIj1gFE59KEJ9THWO/fRuTU+UbW5qOl72Ie4g2tahLudiuHvdmoae0kqFoNrTyr9tnmnX73QgDuWxIktVGSX5LrMyIMQm4CDE5t/lho+fei2r5F2O6bR3jeh1WMjuoPOaPPDBirK16FBJg1cV09QlpSvtR0eX7nP2XaEXtRDjnMpmJAmySxNnYAHO/pCnpRU0rj9sf7FJ7FQG++SfEUUuajre8q3xj58KBzTfzWbe3QtRKvMVXdUHKjeNnBRjOddXxSudmqeKIhT39LizdedtI8hLCU0f0a9xTmGlnuOIapacOAqkqf3tpRP0mE2It+2rjyF4FnnDCWF4pnc2JxGju1CQ+RzhKhoMpmRNQ4rBraKRGvbR//3tNE124qHLggMVzRC2yhR9ceZl/pEHnh7q6AyAvhNMf6oUsdx+QFTFl9X8lC1lM3qmnLNoAKuAuCRLyUKPsy2EiP1671eUOa02JrLLSIm8665omqvd/gIbxCjvN7ZbSffw9488EfUGPGGAiuXxQYqMoCMwugerhD8L2IuL6eS+O6PKtof7OmVpB0tvq4aI96LNXVARmFyfjG6kDFLkwmMFlv7m4uIE3hz1q4pb3yZt8+ZkVsdVz70u3XU8mnj9xLX06nH74znwzbWU6nIXZwjZbMiTwv3pUdRZC4oV38//QMFsoG0DNlZ8SmdDqb+gymVfwOq2CzgWacND2IL/AaZbIgpmS+4uiEVZcgOYdBSae+YeCIs8iARUyo/Qp7E/NiC3PtmhSGm8g4v0lzquxFPHtmJqbxVg50erZxfdXgIQl2sgatD1vcBmoas7VUAceSQg9AJvAf4FmYahkSuHqwQ1tKTr78fCWvB+H5aQpQjhYJCQelu0h0pxjpMVL2GDYy+KKmucanXMmVRxmKMtxC7wwAMkFNJ9DT4kEd1mwdj52SUphHuyAtZFpLWapxGYmoacSF2NmYhdtG65TG/uNInyNQ6z3LFZN5tiXBu46ftvZXXrcIQujyBctzqGTnJuEGInkD14jI5farQZfnz9onRwr2g9dtyYlhNNcpzKPeClNcvo40eKicwu95YzToVJtc/5BEI4lCeQpFSyD67HINGUjbLfZCInkLV4/kn4SUD6aQUuJ5A7X/fEpx6XldGBOsJD8UZDOzXEKicwNRfUatNXVHOHaD9SXW1GG3lHLfanzSfI4Es9Y8tgJd3O7WApha8gREX0eMn4eaZM3YavjrpSEdMIXSS2Pmuv0HRKji8LwPG1WIj0dV4r1WLosvSandtvtG/NWNbGzMSM3ndb2K9xE7OZF4BgwCzkzOZ2PCNxEze8pl5ccvn6JWlVWNVl6FR7OQok+/xmWsJae9IXTPava/HUzTsZUGNxk3OWHNuEe2YL4Qy/cNgvRduAiFtnxFxJMUCWr9Pv6ipPyjqHQK7cuDO1DLbyzAqh+9NdQtaKYGrlf+PGByT0fyhPDZo5j19f6bpCYd6h6iunBRcqEhEodwwQgUeyNFx2JXj9+6DlLgNPiztvj2wKSbyfKhEyqofBuS7NlJ8y8OX/wAhHq/w"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
```ts
const DataAppLink: ({
to,
children,
onClick,
target,
rel,
...rest
}: DataAppLinkProps) => JSX_3.Element;
```
Internal-only navigation link inside a data app.
Renders a plain anchor rather than a router `<Link>`: a data app is mounted
outside the app's route tree, so it has no router context to read.
## Parameters
<!-- [<snippet parameters>] -->
| Parameter | Type |
| :------------------------------------------------ | :---------------------------------------------- |
| `{ to, children, onClick, target, rel, ...rest }` | [`DataAppLinkProps`](./api/DataAppLinkProps.md) |
<!-- [<endsnippet parameters>] -->
## Returns
<!-- [<snippet returns>] -->
[`JSX_3.Element`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6)
<!-- [<endsnippet returns>] -->

View File

@@ -0,0 +1,18 @@
## Extends
<!-- [<snippet extends>] -->
- [`Omit`](https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys)\<[`AnchorHTMLAttributes`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/index.d.ts#L3100)\<[`HTMLAnchorElement`](https://developer.mozilla.org/docs/Web/API/HTMLAnchorElement)\>, `"href"`\>
<!-- [<endsnippet extends>] -->
## Properties
<!-- [<snippet properties>] -->
| Property | Type |
| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| <a id="children"></a> `children?` | [`ReactNode`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/index.d.ts#L478) |
| <a id="to"></a> `to` | `string` |
<!-- [<endsnippet properties>] -->

View File

@@ -0,0 +1,28 @@
```ts
const DataAppRouter: ({ children }: DataAppRouterProps) => JSX_3.Element;
```
Wrap your data-app tree once. Inside, use `<DataAppLink to="…">` for
navigation and `useDataAppLocation()` to read the current path.
No `basename` prop: it is auto-detected from the iframe URL
(`/embed/apps/<name>`). In the dev preview, where there is no prefix, the
basename resolves to `""` and the sub-path is just the raw pathname.
## Parameters
<!-- [<snippet parameters>] -->
| Parameter | Type |
| :------------- | :-------------------------------------------------- |
| `{ children }` | [`DataAppRouterProps`](./api/DataAppRouterProps.md) |
<!-- [<endsnippet parameters>] -->
## Returns
<!-- [<snippet returns>] -->
[`JSX_3.Element`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/jsx-runtime.d.ts#L6)
<!-- [<endsnippet returns>] -->

View File

@@ -0,0 +1,9 @@
## Properties
<!-- [<snippet properties>] -->
| Property | Type |
| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| <a id="children"></a> `children?` | [`ReactNode`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/index.d.ts#L478) |
<!-- [<endsnippet properties>] -->

View File

@@ -0,0 +1,17 @@
```ts
type UseDataAppLocationResult = {
navigate: (to: string) => void;
pathname: string;
};
```
## Properties
<!-- [<snippet properties>] -->
| Property | Type |
| :------------------------------- | :------------------------- |
| <a id="navigate"></a> `navigate` | (`to`: `string`) => `void` |
| <a id="pathname"></a> `pathname` | `string` |
<!-- [<endsnippet properties>] -->

View File

@@ -101,7 +101,12 @@
| Name | Description |
| :---------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [DataAppLink](./api/DataAppLink.md) | Internal-only navigation link inside a data app. |
| [DataAppRouter](./api/DataAppRouter.md) | Wrap your data-app tree once. Inside, use `<DataAppLink to="…">` for navigation and `useDataAppLocation()` to read the current path. |
| [useAction](./api/useAction.md) | - |
| [useDataAppLocation](./api/useDataAppLocation.md) | The current data-app sub-path and a `navigate` function. |
| [DataAppLinkProps](./api/DataAppLinkProps.md) | - |
| [DataAppRouterProps](./api/DataAppRouterProps.md) | - |
| [InteractiveQuestionComponents](./api/InteractiveQuestionComponents.md) | - |
| [MetabaseColors](./api/MetabaseColors.md) | - |
| [MetabaseQuestion](./api/MetabaseQuestion.md) | - |
@@ -169,6 +174,7 @@
| [SdkQuestionTitleProps](./api/SdkQuestionTitleProps.md) | - |
| [SdkUserId](./api/SdkUserId.md) | - |
| [SqlParameterValues](./api/SqlParameterValues.md) | - |
| [UseDataAppLocationResult](./api/UseDataAppLocationResult.md) | - |
| [UserBackendJwtResponse](./api/UserBackendJwtResponse.md) | - |
## useAction

View File

@@ -0,0 +1,16 @@
```ts
const useDataAppLocation: () => UseDataAppLocationResult;
```
The current data-app sub-path and a `navigate` function.
Paths are relative to the data-app root: `/`, `/customers/42`, etc.
`navigate(to)` switches sub-path without a full reload.
## Returns
<!-- [<snippet returns>] -->
[`UseDataAppLocationResult`](./api/UseDataAppLocationResult.md)
<!-- [<endsnippet returns>] -->

View File

@@ -0,0 +1,11 @@
---
title: useDataAppLocation
layout: docs-api
redirect_from:
- /docs/latest/embedding/api/useDataAppLocation
---
<!DOCTYPE html><html class="default" lang="en" data-base="./"><head><meta charset="utf-8"/><meta http-equiv="x-ua-compatible" content="IE=edge"/><title>useDataAppLocation | Embedded analytics SDK API</title><link rel="icon" href="assets/favicon.ico"/>{% include docs/embedded-analytics-sdk-metadata.html %}<meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="assets/style.css"/><link rel="stylesheet" href="assets/highlight.css"/><link rel="stylesheet" href="assets/custom.css"/><script defer src="assets/main.js"></script><script defer src="assets/custom.js"></script><script async src="assets/icons.js" id="tsd-icons-script"></script><script async src="assets/search.js" id="tsd-search-script"></script><script async src="assets/navigation.js" id="tsd-nav-script"></script></head><body><script>document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";document.body.style.display="none";setTimeout(() => window.app?app.showPage():document.body.style.removeProperty("display"),500)</script><header class="tsd-page-toolbar"><div class="tsd-toolbar-contents container"><a href="index.html" class="title">Embedded analytics SDK API</a><div id="tsd-toolbar-links"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })">Back to documentation</a></div><button id="tsd-search-trigger" class="tsd-widget" aria-label="Search"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-search"></use></svg></button><dialog id="tsd-search" aria-label="Search"><input role="combobox" id="tsd-search-input" aria-controls="tsd-search-results" aria-autocomplete="list" aria-expanded="true" autocapitalize="off" autocomplete="off" placeholder="Search the docs" maxLength="100"/><ul role="listbox" id="tsd-search-results"></ul><div id="tsd-search-status" aria-live="polite" aria-atomic="true"><div>Preparing search index...</div></div></dialog><a href="#" class="tsd-widget menu" id="tsd-toolbar-menu-trigger" data-toggle="menu" aria-label="Menu"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-menu"></use></svg></a></div></header><div class="container container-main"><div class="col-content"><div class="tsd-page-title"><ul class="tsd-breadcrumb" aria-label="Breadcrumb"><li><a href="" aria-current="page">useDataAppLocation</a></li></ul><h1>Variable useDataAppLocation<code class="tsd-tag">Const</code></h1></div><div class="tsd-signature"><span class="tsd-kind-variable">useDataAppLocation</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol">()</span> <span class="tsd-signature-symbol">=&gt;</span> <a href="UseDataAppLocationResult.html" class="tsd-signature-type tsd-kind-type-alias">UseDataAppLocationResult</a></div><div class="tsd-comment tsd-typography"><p>The current data-app sub-path and a <code>navigate</code> function.</p>
<p>Paths are relative to the data-app root: <code>/</code>, <code>/customers/42</code>, etc.
<code>navigate(to)</code> switches sub-path without a full reload.</p>
</div><div class="tsd-type-declaration"><h4>Type Declaration</h4><ul class="tsd-parameters"><li class="tsd-parameter-signature"><ul class="tsd-signatures"><li class="tsd-signature" id="__type"><span class="tsd-signature-symbol">()</span><span class="tsd-signature-symbol">:</span> <a href="UseDataAppLocationResult.html" class="tsd-signature-type tsd-kind-type-alias">UseDataAppLocationResult</a></li><li class="tsd-description"><h4 class="tsd-returns-title">Returns <a href="UseDataAppLocationResult.html" class="tsd-signature-type tsd-kind-type-alias">UseDataAppLocationResult</a></h4></li></ul></li></ul></div></div><div class="col-sidebar"><div class="page-menu"><div class="tsd-navigation settings"><details class="tsd-accordion"><summary class="tsd-accordion-summary"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true"><use href="assets/icons.svg#icon-chevronDown"></use></svg><h3>Settings</h3></summary><div class="tsd-accordion-details"><div class="tsd-theme-toggle"><label class="settings-label" for="tsd-theme">Theme</label><select id="tsd-theme"><option value="os">OS</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></details></div></div><div class="site-menu"><nav id="tsd-sidebar-links" class="tsd-navigation"><a href="javascript:navigateBack({ fallbackUrl: '/docs/latest/embedding/sdk/introduction' })" class="tsd-nav-link">Back to documentation</a></nav><nav class="tsd-navigation"><a href="index.html">Embedded analytics SDK API</a><ul class="tsd-small-nested-navigation" id="tsd-nav-container"><li>Loading...</li></ul></nav></div></div></div><footer><p><script type="text/javascript" src="/gdpr-cookie-notice/dist/script.js"></script><script type="text/javascript" src="/js/cookie-consent.js"></script><link href="/gdpr-cookie-notice/dist/style.css" rel="stylesheet" /><link href="/css/gdpr.css" rel="stylesheet" /></p></footer><div class="overlay"></div></body></html>

View File

@@ -145,7 +145,6 @@ show-homepage-data
show-homepage-pin-message
show-homepage-xrays
show-metabot
show-modular-embed-terms
show-sdk-embed-terms
show-simple-embed-terms
show-static-embed-terms

View File

@@ -565,8 +565,10 @@ categories:
url: "cloud/limitations"
- name: "Change region"
url: "cloud/change-region"
- name: "IP addresses to whitelist"
- name: "Metabase IP addresses"
url: "cloud/ip-addresses-to-whitelist"
- name: "IP allowlist"
url: "cloud/ip-allowlist"
- name: "Migrate from self-hosted to Cloud"
url: "cloud/migrate/guide"
- name: "Migrate from Cloud to self-hosted"

View File

@@ -1,4 +1,5 @@
name: Vite 6 Data App
version: 1
path: ./dist/index.js
allowed_hosts:
- https://allowed.data-app.test

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
const { execSync, spawn } = require("child_process");
const { spawn } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -10,8 +10,21 @@ const { waitUntilReady, shell } = require("./cypress-runner-utils");
const tempDbPath = path.join(os.tmpdir(), `metabase-test-${process.pid}.db`);
function getJvmOptsFromDepsEdn(alias = "e2e") {
const cmd = `clojure -Sdeps '{:deps {}}' -M -e '(->> (-> "deps.edn" slurp clojure.edn/read-string :aliases :${alias} :jvm-opts) (clojure.string/join " ") println)'`;
return execSync(cmd, { encoding: "utf8" }).trim().toString();
const depsEdnPath = path.resolve(__dirname, "../../deps.edn");
const depsEdn = fs.readFileSync(depsEdnPath, "utf8");
const aliasBlockPattern = new RegExp(
`:${alias}\\s*\\n\\s*\\{:jvm-opts\\s*\\[([\\s\\S]*?)\\]\\s*\\}`,
"m",
);
const aliasBlock = depsEdn.match(aliasBlockPattern)?.[1];
if (!aliasBlock) {
throw new Error(`Could not find :jvm-opts for alias :${alias} in deps.edn`);
}
const jvmOpts = [...aliasBlock.matchAll(/"((?:\\.|[^"\\])*)"/g)].map(
([, option]) => JSON.parse(`"${option}"`),
);
return jvmOpts.join(" ");
}
// Ensure that the only two required env vars have values

View File

@@ -109,7 +109,8 @@ describe("snapshots", () => {
updateSetting("enable-public-sharing", true);
// interactive is not enabled in the snapshots as it requires a premium feature
// updateSetting("enable-embedding-interactive", true);
updateSetting("enable-embedding-modular", true).then(() => {
updateSetting("enable-embedding-sdk", true);
updateSetting("enable-embedding-static", true).then(() => {
updateSetting("embedding-secret-key", METABASE_SECRET_KEY);
});
// dismiss the license token missing banner, not necessary to render it in every test

View File

@@ -1,2 +1,3 @@
name: Custom Error Component
version: 1
path: ./dist/index.js

View File

@@ -1,2 +1,3 @@
name: Isolation
version: 1
path: ./dist/index.js

View File

@@ -1,2 +1,3 @@
name: Kitchen Sink
version: 1
path: ./dist/index.js

View File

@@ -64,7 +64,10 @@ function Overview() {
<h2 style={{ margin: "0 0 8px" }}>All orders</h2>
<div style={{ height: 400 }}>
{ordersQuery.query ? (
<InteractiveQuestion card={{ query: ordersQuery.query }} />
<InteractiveQuestion
card={{ query: ordersQuery.query }}
withDownloads
/>
) : (
<div>…</div>
)}

View File

@@ -1,2 +1,3 @@
name: Synced Actions App
version: 1
path: ./dist/index.js

View File

@@ -1,2 +1,3 @@
name: Synced App
version: 1
path: ./dist/index.js

View File

@@ -1,3 +1,4 @@
name: Broken Bundle
version: 1
# Points at a file that isn't in the repo — a valid config, an un-loadable app.
path: ./missing.js

View File

@@ -1,3 +1,4 @@
name: Good App
description: A well-formed app that syncs cleanly
version: 1
path: ./index.js

View File

@@ -7,6 +7,12 @@ import "@testing-library/cypress/add-commands";
import { configure } from "@testing-library/cypress";
import "cypress-real-events/support";
import addContext from "mochawesome/addContext";
import {
assertNoDataAppScopeDenials,
resetDataAppScopeGuard,
} from "e2e/support/helpers/e2e-data-app-helpers";
import "./commands";
// Must stay imported after "@cypress/code-coverage/support": its afterEach
// zeroes the window coverage counters after collecting each test's fires,
@@ -196,3 +202,8 @@ beforeEach(function () {
}).as("globalIntercept");
}
});
// A data app's scope rejections are recorded by the intercept H.openDataApp installs; see
// e2e/support/helpers/e2e-data-app-helpers.ts.
beforeEach(resetDataAppScopeGuard);
afterEach(assertNoDataAppScopeDenials);

View File

@@ -16,18 +16,23 @@ import { LOCAL_GIT_PATH } from "./e2e-remote-sync-helpers";
export const DATA_APP_NAME = "kitchen-sink";
export const DATA_APP_DISPLAY_NAME = "Kitchen Sink";
export const visitDataAppRoute = (route: string) =>
cy.visit(`/apps/${DATA_APP_NAME}/${route}`);
export const visitDataAppRoute = (route: string) => {
installDataAppScopeGuard();
return cy.visit(`/apps/${DATA_APP_NAME}/${route}`);
};
export const fakeDataApp = (overrides: Partial<DataApp> = {}): DataApp => ({
id: 1,
name: DATA_APP_NAME,
display_name: DATA_APP_DISPLAY_NAME,
description: null,
version: 1,
outdated: false,
bundle_path: `data_apps/${DATA_APP_NAME}/dist/index.js`,
enabled: true,
resource_collection_id: null,
permission_group_id: null,
table_ids: [],
allowed_hosts: [],
bundle_hash: "e2e-bundle-hash",
last_synced_sha: "e2e0000",
@@ -101,7 +106,71 @@ export const mockDataApp = <TestEnv = DataAppTestEnv>(
});
};
/**
* The two rejections endpoint scope enforcement can emit. `scope_not_permitted` comes from
* `ensure-scopes-checked` — the endpoint declares no `:scope` at all, so a narrowed request
* may not reach it. `unsupported_scope` comes from `enforce-scope` — the endpoint is scoped,
* but not for the scope the request carries. Either one inside a data app means a route the
* SDK really uses was never tagged `data-apps:base`.
*/
const SCOPE_ERRORS = ["scope_not_permitted", "unsupported_scope"];
let scopeDenials: string[] = [];
let scopeGuardInstalled = false;
/**
* Records every scope rejection the backend returns while a data app is open. Every request a
* sandboxed app makes is marked `X-Metabase-Client: data-app` and confined to `data-apps:base`,
* so a rejection here is a route missing its `{:scope api-scope/data-app}` tag, not a
* permission problem. `assertNoDataAppScopeDenials` (a root `afterEach`) fails the test on any.
*/
function installDataAppScopeGuard() {
if (scopeGuardInstalled) {
return;
}
scopeGuardInstalled = true;
cy.intercept("/api/**", (req) => {
// `after:response` rather than a `req.continue` callback: the latter buffers the body,
// which would sit in front of the streamed `/api/dataset` responses.
req.on("after:response", (res) => {
// `res.body` is whatever the endpoint returned; the scope middleware answers with a
// JSON `{error, message}` body.
const error = (res.body as { error?: string } | undefined)?.error;
if (
res.statusCode === 403 &&
error !== undefined &&
SCOPE_ERRORS.includes(error)
) {
scopeDenials.push(
`${req.method} ${new URL(req.url).pathname} -> ${error}`,
);
}
});
});
}
/** Root `beforeEach` in `e2e/support/cypress.js`: intercepts reset between tests, so must this record. */
export function resetDataAppScopeGuard() {
scopeDenials = [];
scopeGuardInstalled = false;
}
/** Root `afterEach` in `e2e/support/cypress.js`. Only a test that opened a data app pays for it. */
export function assertNoDataAppScopeDenials() {
if (!scopeGuardInstalled) {
return;
}
cy.then(() => {
expect(
scopeDenials,
`data-app scope rejections:\n${scopeDenials.join("\n")}`,
).to.be.empty;
});
}
export function openDataApp(slug: string) {
installDataAppScopeGuard();
return cy.visit(Urls.dataApp(slug));
}

View File

@@ -13,7 +13,7 @@ const { WEB_PORT, SMTP_PORT } = WEBMAIL_CONFIG;
/**
* `bun run test-cypress` starts maildev for you via
* e2e/test/scenarios/docker-compose.yml. To start it manually instead:
* `docker run -d -p 1180:1080 -p 1125:1025 maildev/maildev:2.2.1`
* `docker run -d -p 127.0.0.1:1180:1080 -p 127.0.0.1:1125:1025 maildev/maildev:2.2.1`
* or
* `npx maildev -s 1125 -w 1180`
*/

View File

@@ -216,7 +216,7 @@ export function prepareSdkIframeEmbedTest({
activateToken(withToken);
}
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-simple", {
value: true,
});
@@ -256,7 +256,10 @@ export function prepareGuestEmbedSdkIframeEmbedTest({
onPrepare?.();
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-simple", {
value: true,
});
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});

View File

@@ -4,10 +4,8 @@ export const embedModalContent = () =>
export const embedModalEnableEmbeddingCard = () =>
cy.findByTestId("enable-embedding-card");
// The card renders a single button, disabled once the terms are accepted, so
// an enabled one is the accept button and nothing else.
const ACCEPT_TERMS_BUTTON =
'[data-testid="enable-embedding-card"] button:not([disabled])';
const ACCEPT_TERMS_BUTTON_NAME =
/(Agree and (continue|enable)|Enable and continue)/;
export const embedModalEnableEmbedding = () => {
// Wait for the modal before reading the DOM below. That read is a snapshot
@@ -17,21 +15,25 @@ export const embedModalEnableEmbedding = () => {
embedModalContent().should("exist");
cy.get("body").then(($body) => {
// Nothing left to accept. Either no card mounted — terms were accepted in
// the test setup and the section bails early via `showSection` (see
// EnableModularEmbeddingSection / EnableGuestEmbedsSection) — or a section
// that mounted on stale settings settled on its disabled "Enabled" label.
if ($body.find(ACCEPT_TERMS_BUTTON).length === 0) {
// No card mounted — terms were accepted in the test setup, the section
// bails early via `showSection` (see EnableModularEmbeddingSection /
// EnableGuestEmbedsSection) and never renders.
if ($body.find('[data-testid="enable-embedding-card"]').length === 0) {
return;
}
cy.get(ACCEPT_TERMS_BUTTON).click();
// Match the actionable label, not the disabled "Enabled" one — the latter
// also shows transiently on the stale section after an auth-mode switch.
cy.findByRole("button", { name: ACCEPT_TERMS_BUTTON_NAME }).click();
// Once the acceptance registers, the section freezes and relabels its
// button to a disabled "Enabled", so the enabled button going away is the
// signal. Asserting it here makes a lost click fail on the spot instead of
// on a misleading iframe timeout downstream (EMB-2292).
cy.get(ACCEPT_TERMS_BUTTON, { timeout: 10_000 }).should("not.exist");
// button to a disabled "Enabled", so the actionable label going away is
// the signal. Asserting it here makes a lost click fail on the spot
// instead of on a misleading iframe timeout downstream (EMB-2292).
cy.findByRole("button", {
name: ACCEPT_TERMS_BUTTON_NAME,
timeout: 10_000,
}).should("not.exist");
});
};

View File

@@ -1,6 +1,6 @@
/**
Make sure you have the ldap test server running locally:
docker run -p 389:389 \
docker run -p 127.0.0.1:389:389 \
--env LDAP_ADMIN_PASSWORD=adminpass \
--env LDAP_USERS=user01@example.org,user02@example.org \
--env LDAP_PASSWORDS=123456,123465 \

View File

@@ -78,7 +78,7 @@ export function signInAsAdminAndEnableEmbeddingSdk() {
activateToken("bleeding-edge");
enableJwtAuth();
cy.request("PUT", "/api/setting", {
"enable-embedding-modular": true,
"enable-embedding-sdk": true,
// Needed so CORS headers (e.g. Access-Control-Expose-Headers) are sent in responses,
// and we can assert them in tests.

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import {
type MetabaseCard,
StaticQuestion,
} from "@metabase/embedding-sdk-react";
import { useState } from "react";
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
@@ -11,6 +12,8 @@ import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-tes
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
const { H } = cy;
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
// What a data app actually passes: a `card` object built from a query (e.g. the
@@ -72,4 +75,101 @@ describe("scenarios > embedding-sdk > sdk-question card prop", () => {
cy.findByTestId("table-root").should("not.exist");
});
});
describe("card with a not-yet-resolved query (EMB-2390)", () => {
// Data apps mount the question as soon as `useMetabaseQueryObject` returns,
// whose `query` is null until it resolves. The SDK must keep showing its
// loader for that window and never fall through to the "run your code"
// empty state that belongs to a blank question.
const EMPTY_STATE_SELECTOR = 'img[alt="Code prompt icon"]';
const VISUALIZATION_SELECTOR = '[data-testid="visualization-root"]';
const PENDING_QUERY_HOLD_MS = 1500;
function assertEmptyStateNeverShownWhilePending() {
cy.window().then((win) => {
return new Cypress.Promise((resolve, reject) => {
const startedAt = Date.now();
const checkInterval = setInterval(() => {
if (win.document.querySelector(EMPTY_STATE_SELECTOR)) {
clearInterval(checkInterval);
reject(
new Error(
"the empty results state must not show while `card.query` is null",
),
);
} else if (Date.now() - startedAt >= PENDING_QUERY_HOLD_MS) {
clearInterval(checkInterval);
resolve();
}
}, 20);
});
});
}
function assertEmptyStateNeverShownWhileResolving() {
H.assertElementNeverExists({
shouldNotExistSelector: EMPTY_STATE_SELECTOR,
successSelector: VISUALIZATION_SELECTOR,
rejectionMessage:
"the empty results state must not flash between the query resolving and the results rendering",
pollInterval: 20,
timeout: 15000,
});
}
function DeferredInteractiveQuestion() {
const [query, setQuery] = useState<MetabaseCard["query"]>(null);
return (
<div>
<InteractiveQuestion card={{ ...card, query }} />
<button onClick={() => setQuery(card.query)}>Resolve query</button>
</div>
);
}
function DeferredStaticQuestion() {
const [query, setQuery] = useState<MetabaseCard["query"]>(null);
return (
<div>
<StaticQuestion card={{ ...card, query }} />
<button onClick={() => setQuery(card.query)}>Resolve query</button>
</div>
);
}
it("InteractiveQuestion should show a loader instead of the empty results state until `card.query` resolves", () => {
mountSdkContent(<DeferredInteractiveQuestion />);
getSdkRoot().within(() => {
assertEmptyStateNeverShownWhilePending();
cy.findByTestId("loading-indicator").should("be.visible");
});
cy.findByRole("button", { name: "Resolve query" }).click();
assertEmptyStateNeverShownWhileResolving();
getSdkRoot().within(() => {
cy.findByTestId("visualization-root").should("be.visible");
});
});
it("StaticQuestion should show a loader instead of the empty results state until `card.query` resolves", () => {
mountSdkContent(<DeferredStaticQuestion />);
getSdkRoot().within(() => {
assertEmptyStateNeverShownWhilePending();
cy.findByTestId("loading-indicator").should("be.visible");
});
cy.findByRole("button", { name: "Resolve query" }).click();
assertEmptyStateNeverShownWhileResolving();
getSdkRoot().within(() => {
cy.findByTestId("visualization-root").should("be.visible");
});
});
});
});

View File

@@ -883,7 +883,7 @@ describe("scenarios > admin > settings > map settings", () => {
});
// Ensure the webhook tester docker container is running
// docker run -p 9080:8080/tcp tarampampam/webhook-tester:1.1.0 serve --create-session 00000000-0000-0000-0000-000000000000
// docker run -p 127.0.0.1:9080:8080/tcp tarampampam/webhook-tester:1.1.0 serve --create-session 00000000-0000-0000-0000-000000000000
describe("notifications", { tags: "@external" }, () => {
beforeEach(() => {
H.resetWebhookTester();

View File

@@ -19,7 +19,7 @@ describe("scenarios > admin > settings > SSO > JWT", () => {
);
cy.button("Set up key").click();
H.modal().within(() => {
cy.button("Create").click();
cy.button("Done").click();
});
cy.button("Save and enable").click();
cy.wait("@updateSettings");
@@ -81,14 +81,10 @@ describe("scenarios > admin > settings > SSO > JWT", () => {
cy.button("Regenerate key").click();
H.modal().within(() => {
cy.findByText("Delete key and generate a new one?").should("exist");
cy.findByText("Set up secret key").should("exist");
cy.findByText(
"This will cause existing tokens to stop working until the identity provider is updated with a new key.",
"This will cause existing tokens to stop working until the identity provider is updated with the new key.",
).should("exist");
cy.button("Delete key").click();
});
H.modal().within(() => {
cy.findByText("Store your new key").should("exist");
cy.button("Done").click();
});
cy.button("Save changes").click();

View File

@@ -9,8 +9,10 @@ export const uploadTranslationDictionary = (rows: DictionaryArray) => {
cy.intercept("POST", "/api/ee/content-translation/upload-dictionary").as(
"uploadDictionary",
);
cy.intercept("GET", "/api/setting").as("getSettings");
cy.signInAsAdmin();
cy.visit("/embedding/localization");
cy.visit("/admin/embedding");
cy.wait("@getSettings");
cy.findByTestId("content-localization-setting").findByText(
/Upload edited translation dictionary/,

View File

@@ -29,7 +29,7 @@ describe("scenarios > admin > embedding > guest embeds> content translation", ()
});
it("admin settings configuration form is not present", () => {
cy.visit("/embedding/localization");
cy.visit("/admin/embedding/guest");
cy.findByTestId("content-translation-configuration").should("not.exist");
});
});
@@ -56,7 +56,7 @@ describe("scenarios > admin > embedding > guest embeds> content translation", ()
describe("The translation download button", () => {
it("downloads the stored translations", () => {
uploadTranslationDictionaryViaAPI(germanFieldNames);
cy.visit("/embedding/localization");
cy.visit("/admin/embedding");
cy.findByTestId("content-translation-configuration")
.button(/Get translation dictionary template/i)
.click();
@@ -184,7 +184,7 @@ describe("scenarios > admin > embedding > guest embeds> content translation", ()
});
it("rejects, in the frontend, a CSV upload that is too big", () => {
cy.visit("/embedding/localization");
cy.visit("/admin/embedding");
cy.get("#content-translation-dictionary-upload-input").selectFile(
{
contents: Cypress.Buffer.from(
@@ -210,7 +210,7 @@ describe("scenarios > admin > embedding > guest embeds> content translation", ()
});
it("rejects invalid CSV", () => {
cy.visit("/embedding/localization");
cy.visit("/admin/embedding");
const validCSV = getCSVWithHeaderRow(germanFieldNames);
const invalidCSV = validCSV + '\nde,Price,"Preis"X';

View File

@@ -4,6 +4,7 @@ const { H } = cy;
const PATH = "/admin/metabot/mcp/authorizations";
const REDIRECT_URI = "https://example.com/callback";
const SCOPE = "agent:content:read";
/**
* End-to-end coverage for the OAuth Authorizations admin page.
@@ -110,8 +111,8 @@ function denyClient(client: RegisteredClient) {
/**
* Drive the consent flow to a decision for a registered client, recording an `approved` or `denied`
* event stamped with the signed-in user. `scope` is omitted (it's optional, and the DCR client's
* agent scopes don't include `profile`); the client must be confidential so the public-client PKCE
* event stamped with the signed-in user. `/oauth/authorize` requires a scope, and the DCR client's
* default scopes include `SCOPE`; the client must be confidential so the public-client PKCE
* requirement doesn't apply. Mirrors the real browser flow: GET the consent page, lift the CSRF
* token + params signature from its hidden fields, then POST the decision (Cypress carries the
* CSRF cookie). Both approve and deny redirect (302).
@@ -121,7 +122,7 @@ function decideClient(client: RegisteredClient, approved: boolean) {
const authorizeUrl =
`/oauth/authorize?client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
"&response_type=code&state=test-state";
`&response_type=code&scope=${encodeURIComponent(SCOPE)}&state=test-state`;
return cy.request("GET", authorizeUrl).then(({ body }) => {
cy.request({
@@ -136,6 +137,7 @@ function decideClient(client: RegisteredClient, approved: boolean) {
client_id: clientId,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: SCOPE,
state: "test-state",
},
})

View File

@@ -866,7 +866,7 @@ describe("Remote Sync", () => {
H.copySyncedCollectionFixture();
H.commitToRepo();
// Enable static embedding instance-wide so the Embed option is offered.
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-static", true);
H.configureGitAndPullChanges("read-only");
cy.visit("/");

View File

@@ -1337,7 +1337,7 @@ describe("scenarios > collection items listing", () => {
cy.findByRole("button", { name: "Filter" }).click();
H.popover().within(() => {
cy.findAllByRole("checkbox").should("have.length", 7);
cy.findAllByRole("checkbox").should("have.length", 8);
cy.findByLabelText("Collection").should("not.be.checked");
cy.findByLabelText("Dashboard").should("not.be.checked");
cy.findByLabelText("Question").should("not.be.checked");

View File

@@ -2,6 +2,7 @@ import {
DATA_APP_DISPLAY_NAME as APP_DISPLAY_NAME,
DATA_APP_NAME as APP_NAME,
} from "e2e/support/helpers";
import type { DataApp } from "metabase-types/api";
import { DATA_APP_TEST_ENV as TEST_ENV } from "./helpers";
@@ -96,6 +97,69 @@ describe("scenarios > data apps > admin management", () => {
.findByText(/AI-generated React apps/)
.should("not.exist");
});
describe("outdated apps", () => {
// Only a bump of the supported version makes a real app outdated, so the flag
// the API computes is patched onto a real app's responses instead.
const OUTDATED_APP = "orders-app";
function markAppOutdated() {
cy.request<DataApp>("POST", `/api/apps/${OUTDATED_APP}/draft`)
.its("body.display_name")
.as("outdatedAppName");
cy.intercept("GET", "/api/apps", (req) => {
req.continue((res) => {
res.body = res.body.map((app: DataApp) =>
app.name === OUTDATED_APP
? { ...app, version: 1, outdated: true }
: app,
);
});
});
cy.intercept(
{ method: "GET", pathname: `/api/apps/${OUTDATED_APP}` },
(req) => {
req.continue((res) => {
res.body = { ...res.body, version: 1, outdated: true };
});
},
);
}
it("badges an outdated app, refuses to open it, and still lets an admin manage its users", () => {
markAppOutdated();
cy.visit("/admin/settings/apps");
cy.get<string>("@outdatedAppName").then((displayName) => {
cy.findByTestId(`data-app-list-item-${OUTDATED_APP}`)
.scrollIntoView()
.within(() => {
cy.findByText("Outdated").should("be.visible");
cy.findByText(displayName).should("be.visible");
cy.findByRole("link", { name: displayName }).should("not.exist");
cy.findByRole("button", {
name: `Actions for ${displayName}`,
}).click();
});
});
H.popover().findByText("Manage user access").click();
cy.location("pathname").should(
"eq",
`/admin/settings/apps/${OUTDATED_APP}/users`,
);
H.main()
.findByRole("heading", { name: "Manage access to this app" })
.should("be.visible");
H.openDataApp(OUTDATED_APP);
H.main().findByText("This data app is outdated").should("be.visible");
cy.get("iframe").should("not.exist");
});
});
});
// TODO(v65): data apps launch in v65 — replace the "no token" suite below with

View File

@@ -0,0 +1,110 @@
import {
DATA_APP_DISPLAY_NAME as APP_DISPLAY_NAME,
DATA_APP_NAME as APP_NAME,
} from "e2e/support/helpers";
import { DATA_APP_TEST_ENV as TEST_ENV } from "./helpers";
const { H } = cy;
const TIMEOUT = 30000;
describe("scenarios > data apps > backend scope", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
});
/**
* The unit and middleware tests can only assert the endpoints we already thought to list.
* This one works the other way round: drive the SDK surface a data app actually exposes and
* let the backend tell us what it refuses. The scope guard `H.openDataApp` installs records
* every rejection and the root `afterEach` fails the test on any, so this spec only has to
* exercise the surface.
*/
it("serves the whole InteractiveQuestion surface without refusing a request for scope", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
H.openDataApp(APP_NAME);
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("data-app-content", { timeout: TIMEOUT }).should("exist");
// The toolbar only mounts once the first query has come back, so this doubles as the
// wait for `/api/dataset`.
cy.findByTestId("interactive-question-result-toolbar", {
timeout: TIMEOUT,
}).should("exist");
cy.log("Chart type picker — loads visualization metadata");
cy.findByTestId("chart-type-selector-button").click();
cy.findByText("More charts").should("be.visible");
// Toggle the trigger to close: key events would land on the parent document, not the
// iframe, and Mantine treats a stray Escape as a modal dismiss.
cy.findByTestId("chart-type-selector-button").click();
cy.log("Visualization settings");
cy.findByTestId("viz-settings-button").click();
cy.findByTestId("viz-settings-button").click();
cy.log("Filter picker — loads table metadata and field values");
cy.findByTestId("filter-dropdown-button").click();
// This `within` is the whole iframe, so a column name also matches the table
// rendered behind the picker. Pick from the popover the button just opened.
cy.get('[data-element-id="mantine-popover"]')
.should("have.length.above", 0)
.last()
.within(() => {
cy.findByText("Total").click();
});
cy.findByText("Add filter").click();
cy.log("Summarize picker — loads the aggregation/column pickers");
// The button is labelled by how many summaries the question has, and with
// none it opens the aggregation picker directly — the badge list, with its
// "Add another summary", is what a question that already aggregates opens.
cy.findByText("Summarize").click();
cy.findByTestId("aggregation-picker").should("be.visible");
cy.findByText("Summarize").click();
cy.log("Group-by picker");
cy.findByText("Group").click();
cy.findByText("Group").click();
cy.log(
"Download menu — loads the export formats and downloads preference",
);
cy.findByTestId("question-download-widget-button").click();
cy.findByTestId("question-download-widget-button").click();
cy.log("Notebook editor — loads databases, schemas, tables and fields");
cy.findByTestId("notebook-button").click();
cy.findByText("Back to visualization", { timeout: TIMEOUT }).should(
"be.visible",
);
cy.log("Add a notebook filter step");
cy.findByTestId("action-buttons").findByText("Filter").click();
cy.get('[data-element-id="mantine-popover"]')
.should("have.length.above", 0)
.last()
.within(() => {
// A sparse float has no cached field values, so the picker defaults to
// Between and offers Min/Max. A low-cardinality column like Quantity
// defaults to `=` and offers a value list instead.
cy.findByText("Discount").click();
});
cy.findByPlaceholderText("Min").type("2");
cy.findByText("Add filter").click();
cy.log("Re-run the edited query");
cy.findByText("Visualize").click();
cy.findByText("Back to visualization", { timeout: TIMEOUT }).should(
"not.exist",
);
});
});
});

View File

@@ -12,7 +12,7 @@ describe("scenarios > data apps > sandbox isolation", () => {
H.activateToken("bleeding-edge");
});
const setUp = () => {
const setup = () => {
const instanceUrl = Cypress.config("baseUrl") ?? "";
const testEnv: IsolationTestEnv = { instanceUrl };
@@ -63,177 +63,177 @@ describe("scenarios > data apps > sandbox isolation", () => {
};
it("keeps a document.createElement about:blank iframe within the gated realm", () => {
setUp();
setup();
runProbe("isolation-create-element");
});
it("gates a host-React about:blank iframe", () => {
setUp();
setup();
runProbeExpectingGuard("isolation-react-about-blank");
});
it("gates an iframe pointing at Metabase itself", () => {
setUp();
setup();
runProbeExpectingGuard("isolation-react-src");
});
it("gates a srcdoc iframe", () => {
setUp();
setup();
runProbeExpectingGuard("isolation-react-srcdoc");
});
it("keeps a window.open realm within the gated realm", () => {
setUp();
setup();
runProbe("isolation-window-open");
});
it("gates the Worker constructor", () => {
setUp();
setup();
runProbe("isolation-worker");
});
it("gates the SharedWorker constructor", () => {
setUp();
setup();
runProbe("isolation-shared-worker");
});
it("gates the service worker registration API", () => {
setUp();
setup();
runProbe("isolation-service-worker");
});
it("gates dynamic import", () => {
setUp();
setup();
runProbe("isolation-dynamic-import");
});
it("keeps a dangerouslySetInnerHTML iframe within the gated realm", () => {
setUp();
setup();
runProbe("isolation-inner-html");
});
it("keeps a Function-constructor fetch gated", () => {
setUp();
setup();
runProbe("isolation-function-constructor");
});
it("keeps a DOMParser iframe within the gated realm", () => {
setUp();
setup();
runProbe("isolation-dom-parser");
});
it("keeps a createHTMLDocument iframe adopted into the realm gated", () => {
setUp();
setup();
runProbe("isolation-adopt-html-doc-iframe");
});
it("keeps a createHTMLDocument iframe imported into the realm gated", () => {
setUp();
setup();
runProbe("isolation-import-html-doc-iframe");
});
it("keeps a createDocument iframe adopted into the realm gated", () => {
setUp();
setup();
runProbe("isolation-xml-doc-iframe");
});
it("keeps a template owner-document iframe adopted into the realm gated", () => {
setUp();
setup();
runProbe("isolation-template-doc-iframe");
});
it("keeps a raw API out of the SDK endowments", () => {
setUp();
setup();
runProbe("isolation-endowment-api");
});
it("keeps an Error.prepareStackTrace realm reference gated", () => {
setUp();
setup();
runProbe("isolation-stack-trace-realm");
});
it("keeps window.parent within the gated realm", () => {
setUp();
setup();
runProbe("isolation-window-parent");
});
it("keeps window.top within the gated realm", () => {
setUp();
setup();
runProbe("isolation-window-top");
});
it("keeps window.frameElement's owner realm gated", () => {
setUp();
setup();
runProbe("isolation-frame-element");
});
it("keeps window.parent.parent within the gated realm", () => {
setUp();
setup();
runProbe("isolation-parent-chain");
});
it("gates document.cookie on the parent realm", () => {
setUp();
setup();
runProbe("isolation-parent-cookie");
});
it("gates localStorage on the parent realm", () => {
setUp();
setup();
runProbe("isolation-parent-local-storage");
});
it("gates sessionStorage on the parent realm", () => {
setUp();
setup();
runProbe("isolation-parent-session-storage");
});
it("gates indexedDB on the parent realm", () => {
setUp();
setup();
runProbe("isolation-parent-indexeddb");
});
it("gates caches on the parent realm", () => {
setUp();
setup();
runProbe("isolation-parent-caches");
});
it("keeps indexed window.frames access gated", () => {
setUp();
setup();
runProbe("isolation-window-frames");
});
it("keeps window.opener's realm gated", () => {
setUp();
setup();
runProbe("isolation-window-opener");
});
it("gates FontFace.load", () => {
setUp();
setup();
runProbe("isolation-font-face");
});
it("gates cookieStore", () => {
setUp();
setup();
runProbe("isolation-cookie-store");
});
it("gates performance resource timing", () => {
setUp();
setup();
runProbe("isolation-perf-resource-timing");
});
it("keeps a Range.createContextualFragment iframe within the gated realm", () => {
setUp();
setup();
runProbe("isolation-range-fragment-iframe");
});
it("keeps a custom element's upgrade callback in the gated realm", () => {
setUp();
setup();
runProbe("isolation-custom-element");
});
it("keeps a host iframe's realm gated", () => {
setUp();
setup();
// A srcless (about:blank) iframe is same-origin, so its `contentWindow` is a
// live realm with an un-gated `fetch` — the same capability html2canvas's
@@ -289,4 +289,37 @@ describe("scenarios > data apps > sandbox isolation", () => {
runProbe("isolation-allowed-host-redirect");
});
// The 403s the marker produces are backend behaviour, covered by
// `data_app_scope_test.clj`. What only e2e can prove is the premise those 403s rest
// on: that the real transport stamps `X-Metabase-Client: data-app` on the requests the
// SDK makes from inside the sandbox. The header cannot be spoofed to gain access —
// host-realm code the membraned guest can't reach sets it, and it only ever narrows —
// but if it ever stopped being sent, the confinement would silently stop applying.
it("marks the requests the SDK makes from inside the sandbox as data-app", () => {
const markedPaths = new Set<string>();
cy.intercept("/api/**", (req) => {
if (req.headers["x-metabase-client"] === "data-app") {
markedPaths.add(new URL(req.url).pathname);
}
});
setup();
// Wait for the guest bundle to have rendered — the SDK's bootstrap requests are
// still in flight while it loads, so asserting earlier races them.
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("isolation-result", { timeout: 30000 }).should("exist");
});
// `/api/user/current` is the whole marked surface this fixture produces — it renders
// isolation probes, not questions, and the SDK's bootstrap takes site settings from
// the auth prefetch rather than refetching `/api/session/properties`.
cy.then(() => {
expect([...markedPaths], "requests marked as data-app").to.include(
"/api/user/current",
);
});
});
});

View File

@@ -192,6 +192,36 @@ describe("scenarios > data apps > viewing & routing", () => {
});
});
describe("outdated apps", () => {
it("tells a non-admin why an outdated app won't open", () => {
H.mockDataApp(APP_NAME, { displayName: APP_DISPLAY_NAME });
// The metadata endpoint refuses an outdated app to non-admins with this
// exact shape (see `check-not-outdated` in data_apps/api.clj).
cy.intercept(
{ method: "GET", pathname: `/api/apps/${APP_NAME}` },
{
statusCode: 409,
body: {
"error-code": "data-app-outdated",
message: "This app was built for version 1 of data apps.",
},
},
);
cy.signInAsNormalUser();
H.openDataApp(APP_NAME);
H.main().within(() => {
cy.findByText("This data app is outdated").should("be.visible");
cy.findByText("This app was built for version 1 of data apps.").should(
"be.visible",
);
});
cy.get("iframe").should("not.exist");
});
});
describe("host error / not-ready screens", () => {
it("shows a themed host error screen when the bundle throws while rendering", () => {
H.mockDataApp(APP_NAME, {

View File

@@ -6,6 +6,9 @@ const DB_NAME = "Writable Postgres12";
const SOURCE_TABLE = "Animals";
const TARGET_TABLE = "transform_table";
const TARGET_SCHEMA = "Schema A";
const TRANSFORM_DETAIL_TIMEOUT = 10_000;
// Python runs asynchronously move data through S3 and an external runner before importing the result.
const PYTHON_TRANSFORM_RUN_TIMEOUT = 20_000;
describe("scenarios > admin > transforms incremental", () => {
beforeEach(() => {
@@ -194,12 +197,19 @@ def transform(animals):
cy.button("Save").click();
cy.wait("@createTransform").then(({ response }) => {
const transformId = response?.body?.id;
if (transformId != null) {
cy.wrap(transformId).as("transformId");
}
expect(response?.statusCode).to.equal(200);
expect(transformId).to.be.a("number");
});
});
cy.location("pathname", { timeout: TRANSFORM_DETAIL_TIMEOUT }).should(
"match",
/^\/data-studio\/transforms\/\d+$/,
);
cy.findByTestId("transforms-header", {
timeout: TRANSFORM_DETAIL_TIMEOUT,
}).should("be.visible");
cy.log("run the transform and make sure its table can be queried");
H.DataStudio.Transforms.runTab().click();
runTransformAndWaitForSuccess();
@@ -394,9 +404,11 @@ function visitTransformListPage() {
return cy.visit("/data-studio/transforms");
}
function runTransformAndWaitForSuccess() {
function runTransformAndWaitForSuccess(
options: { timeout?: number } = { timeout: PYTHON_TRANSFORM_RUN_TIMEOUT },
) {
getRunButton().click();
getRunButton().should("have.text", "Ran successfully");
getRunButton(options).should("have.text", "Ran successfully");
}
function getRunButton(options: { timeout?: number } = {}) {

View File

@@ -9,32 +9,32 @@ services:
- TZ=US/Pacific
image: metabase/qa-databases:postgres-sample-12
ports:
- 5404:5432
- 127.0.0.1:5404:5432
mongo-sample:
image: metabase/qa-databases:mongo-sample-5
ports:
- 27004:27017
- 127.0.0.1:27004:27017
mysql-sample:
environment:
- TZ=US/Pacific
image: metabase/qa-databases:mysql-sample-8
ports:
- 3304:3306
- 127.0.0.1:3304:3306
webhook-tester:
image: tarampampam/webhook-tester:1.1.0
ports:
- 9080:8080
- 127.0.0.1:9080:8080
command: serve --create-session 00000000-0000-0000-0000-000000000000
maildev:
image: maildev/maildev:2.2.1
## Host ports deliberately differ from the 1080/1025 used for development
ports:
- "1180:1080"
- "1125:1025"
- "127.0.0.1:1180:1080"
- "127.0.0.1:1125:1025"
maildev-ssl:
## NOTE: Using this requires a root CA certificate to be added to the Java keystore.
@@ -43,8 +43,8 @@ services:
## breaks the SSL/SMTP setup and fails the custom SMTP e2e tests.
image: maildev/maildev:2.2.1
ports:
- "1081:1081"
- "465:465"
- "127.0.0.1:1081:1081"
- "127.0.0.1:465:465"
environment:
NODE_TLS_REJECT_UNAUTHORIZED: 0
MAILDEV_WEB_PORT: 1081
@@ -58,7 +58,7 @@ services:
saml-idp:
image: boxyhq/mock-saml:1.4.2
ports:
- "8090:4000"
- "127.0.0.1:8090:4000"
environment:
APP_URL: http://localhost:8090
ENTITY_ID: https://saml.example.com/entityid
@@ -68,7 +68,7 @@ services:
ldap:
image: bitnamilegacy/openldap:2.6.4
ports:
- "389:389"
- "127.0.0.1:389:389"
environment:
LDAP_ADMIN_PASSWORD: adminpass
LDAP_USERS: user01@example.org,user02@example.org

View File

@@ -1,23 +0,0 @@
const { H } = cy;
describe("scenarios > embedding > admin route redirects (EMB-1526)", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
// admin/routes.unit.spec.tsx asserts the whole retired-path -> hub table off
// the route tree. This covers the part reading the tree cannot: that a
// redirect actually fires in a browser, and that it carries its params over.
it("redirects retired admin embedding URLs to the hub", () => {
cy.visit("/admin/embedding");
cy.location("pathname").should("eq", "/embedding/security");
cy.visit("/admin/embedding/themes/some-theme-id");
cy.location("pathname").should(
"eq",
"/embedding/appearance/theme/some-theme-id",
);
});
});

View File

@@ -0,0 +1,66 @@
const { H } = cy;
describe(
"scenarios > embedding > admin settings > oss",
{ tags: "@OSS" },
() => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.updateSetting("show-sdk-embed-terms", false);
});
it("shows all embedding types without the setup guide", () => {
cy.log("Navigate to Embedding admin section");
cy.visit("/admin/embedding");
cy.log("Check that we're on the embedding settings page");
cy.url().should("include", "/admin/embedding");
cy.get("main").findByText("Embedding settings").should("be.visible");
cy.log("Verify sidebar does not contain setup guide");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Setup guide/ })
.should("not.exist");
cy.log("Verify sidebar does not contain guest embeds link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Guest embeds/ })
.should("not.exist");
cy.log("Verify sidebar contains security settings link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Security/ })
.should("exist");
});
it("should show embedding upsell on oss", () => {
cy.visit("/admin/embedding/interactive");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByRole("heading", { name: "Embedding settings" }).should(
"be.visible",
);
cy.log("upsell gem icon should be visible");
cy.icon("gem").should("be.visible");
cy.findByRole("link", { name: "Upgrade" })
.should("have.attr", "href")
.and(
"eq",
"https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embedding-page&source_plan=oss&utm_users=10&utm_campaign=embedding-methods",
);
});
});
it("should show CORS setting on security page", () => {
cy.visit("/admin/embedding/security");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByText("Cross-Origin Resource Sharing (CORS)").should("exist");
});
});
},
);

View File

@@ -0,0 +1,57 @@
const { H } = cy;
describe("scenarios > embedding > admin settings > starter", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("starter");
H.updateSetting("show-sdk-embed-terms", false);
});
it("shows all embedding types without the setup guide", () => {
cy.log("Navigate to Embedding admin section");
cy.visit("/admin/embedding");
cy.log("Check that we're on the embedding settings page");
cy.url().should("include", "/admin/embedding");
cy.get("main").findByText("Embedding settings").should("be.visible");
cy.log("Verify sidebar does not contain setup guide");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Setup guide/ })
.should("not.exist");
cy.log("Verify sidebar does not contain guest embeds link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Guest embeds/ })
.should("not.exist");
cy.log("Verify sidebar contains security settings link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Security/ })
.should("exist");
});
it("should show embedding upsell on oss", () => {
cy.visit("/admin/embedding/interactive");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByRole("heading", { name: "Embedding settings" }).should(
"be.visible",
);
cy.log("upsell gem icon should be visible");
cy.icon("gem").should("be.visible");
});
});
it("should show CORS setting on security page", () => {
cy.visit("/admin/embedding/security");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByText("Cross-Origin Resource Sharing (CORS)").should("exist");
});
});
});

View File

@@ -1,773 +0,0 @@
import { SAMPLE_DB_TABLES } from "e2e/support/cypress_data";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import { createThemeViaApi } from "./embedding-theme-editor/helpers";
const { H } = cy;
const { STATIC_ORDERS_ID } = SAMPLE_DB_TABLES;
/**
* The embedding hub at `/embedding` -- the tabbed section, not the older
* onboarding checklist that `onboarding/embedding-homepage.cy.spec.ts` covers.
* One describe per tab; each tab adds its own as it lands.
*
* Deliberately thin: card copy, the upsell banner and the AI card's done states
* are unit-tested next to their components. What is here is what needs a real
* browser or a real backend -- routing, the two step orderings, and completion
* coming back from the checklist endpoint.
*
* Get started's blocks are both `@EE`, because the steps themselves are: tenants,
* SSO and data segregation only exist on an enterprise build. The unlicensed case
* is that same build with no token activated, which is what the locked states are
* about.
*/
const CARD = "embedding-hub-checklist-card";
const PRO_STEP_ORDER = [
"Connect a database",
"Create a dashboard",
"Get embed snippet",
"Configure data permissions and tenants",
"Set up SSO",
"Embed in production with SSO",
"Create a custom theme",
"Configure AI",
];
// Without modular embedding every Fine-tune step is locked, so AI -- the one
// advanced step still reachable -- is promoted into the first section.
const UNLICENSED_STEP_ORDER = [
"Connect a database",
"Create a dashboard",
"Get embed snippet",
"Configure AI",
"Configure data permissions and tenants",
"Set up SSO",
"Embed in production with SSO",
"Create a custom theme",
];
// Every plan sees every tab; the gem marks the ones its token can't unlock.
// OSS and Starter lack all four gated features, so they expect the same.
const TAB_GEMS_WITHOUT_PAID_FEATURES = [
{ label: "Get started", hasGem: false },
{ label: "Security", hasGem: false },
{ label: "Authentication", hasGem: true },
{ label: "Permissions", hasGem: false },
{ label: "Tenancy", hasGem: true },
{ label: "Appearance", hasGem: true },
{ label: "Localization", hasGem: true },
];
function assertStepOrder(titles: string[]) {
cy.findAllByTestId(CARD)
.should("have.length", titles.length)
.each(($card, index) => {
cy.wrap($card).should("contain.text", titles[index]);
});
}
function assertHubTabs(tabs: { label: string; hasGem: boolean }[]) {
cy.findByRole("navigation", { name: "Embedding hub" }).within(() => {
// Every tab is a link and nothing else in the nav is, so the count makes
// the list exhaustive -- a tab the hub should not have fails here.
cy.findAllByRole("link").should("have.length", tabs.length);
for (const { label, hasGem } of tabs) {
cy.findByRole("link", { name: label })
.findByTestId("upsell-gem")
.should(hasGem ? "exist" : "not.exist");
}
});
}
describe("scenarios > embedding > embedding hub > get started", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("redirects from the hub root and lists the licensed step order", () => {
cy.visit("/embedding");
cy.log("the hub root is the Get started tab");
cy.location("pathname").should("eq", "/embedding/get-started");
cy.findByRole("link", { name: "Get started" }).should(
"have.attr",
"aria-current",
"page",
);
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByRole("heading", {
name: "Get started with Metabase Embedding",
}).should("be.visible");
assertStepOrder(PRO_STEP_ORDER);
});
});
it("returns to Get started from the permissions wizard", () => {
cy.visit("/embedding/get-started");
cy.findByTestId("embedding-hub-main")
.findByRole("link", { name: "Configure data permissions and tenants" })
.click();
cy.location("pathname").should(
"eq",
"/embedding/get-started/permissions",
);
cy.log(
"the wizard's back link is relative, so it lands on the host that mounted it rather than admin",
);
cy.findByRole("link", { name: /Back to the setup guide/ }).click();
cy.location("pathname").should("eq", "/embedding/get-started");
});
it("finishes the permissions wizard and lands back on Get started", () => {
cy.log("seed every step the wizard checks, so Summary is the one left");
H.updateSetting("use-tenants", true);
cy.request("POST", "/api/collection", {
name: "Shared collection",
namespace: "shared-tenant-collection",
}).then(({ body: sharedCollection }) => {
H.createDashboard({
name: "Tenant dashboard",
collection_id: sharedCollection.id,
});
});
cy.request("POST", "/api/ee/tenant", {
name: "Test Tenant",
slug: "test-tenant",
});
cy.request("POST", "/api/permissions/group", { name: "Test Group" }).then(
({ body: group }) => {
cy.sandboxTable({ table_id: STATIC_ORDERS_ID, group_id: group.id });
},
);
cy.visit("/embedding/get-started/permissions");
cy.log("the Summary step closes the wizard out");
cy.findByRole("button", { name: "Done" }).click();
cy.location("pathname").should("eq", "/embedding/get-started");
cy.findByTestId("embedding-hub-main").should("be.visible");
});
it("finishes the SSO wizard and lands back on Get started", () => {
cy.log("JWT configured is what unlocks the two steps after it");
enableJwtAuth();
cy.visit("/embedding/get-started/sso");
cy.findByRole("button", { name: "Next" }).click();
cy.log("confirming the manual login test is what closes the wizard out");
// A link, not a button: it is a `Button component={Link}`, so the step's
// own navigation is what returns to the hub.
cy.findByRole("link", { name: "Log in works, I'm done" }).click();
cy.location("pathname").should("eq", "/embedding/get-started");
cy.findByTestId("embedding-hub-main").should("be.visible");
});
});
describe("unlicensed", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("promotes AI into the first section and locks the Fine-tune steps", () => {
cy.visit("/embedding/get-started");
cy.findByTestId("embedding-hub-main").within(() => {
assertStepOrder(UNLICENSED_STEP_ORDER);
cy.log(
"the upsell banner is what stands in for the Fine-tune subtitle",
);
cy.findByText(
"Upgrade to Metabase Pro to configure advanced options.",
).should("be.visible");
cy.log("a feature-locked step is inert, with no link to follow");
cy.findByRole("link", { name: "Set up SSO" }).should("not.exist");
cy.findByText("Set up SSO")
.closest(`[data-testid=${CARD}]`)
.should("have.attr", "aria-disabled", "true");
cy.log("AI is never locked -- the admin AI page needs no token");
cy.findByRole("button", { name: "Configure AI" }).should("be.visible");
});
});
it("marks the embed step complete once a guest embed is published", () => {
cy.visit("/embedding/get-started");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByText("Get embed snippet")
.closest(`[data-testid=${CARD}]`)
.findByLabelText("Step 3 complete")
.should("not.exist");
});
cy.log("publish a dashboard as a guest embed");
H.createDashboard({ name: "Published dashboard" }).then(
({ body: dashboard }) => {
cy.request("PUT", `/api/dashboard/${dashboard.id}`, {
enable_embedding: true,
});
},
);
cy.log(
"the checklist is served without a licence, so completion is real rather than all-false",
);
cy.visit("/embedding/get-started");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByText("Get embed snippet")
.closest(`[data-testid=${CARD}]`)
.findByLabelText("Step 3 complete")
.should("exist");
});
});
});
});
describe("scenarios > embedding > embedding hub > security", () => {
describe("pro", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
// The snapshot turns embedding on, but this test is about turning it on
// from the hub, so start from off the way a fresh instance would be.
H.updateSetting("enable-embedding-modular", false);
});
it("turns embedding on with one switch", () => {
cy.intercept("GET", "/api/setting").as("getSettings");
cy.intercept("GET", "/api/session/properties").as("getSessionProperties");
cy.visit("/embedding/security");
cy.log("The first enable goes through the terms modal");
cy.findByTestId("embedding-hub-main")
.findByText("Modular embedding and SDK for React")
.should("be.visible");
// The switch renders before either request lands, reading undefined as
// off and as terms-already-seen, so a click before then writes the
// settings directly instead of opening the modal.
cy.wait(["@getSettings", "@getSessionProperties"]);
cy.findAllByRole("switch").first().should("not.be.checked").click();
cy.findByRole("button", { name: "Agree" }).click();
cy.log("The merged setting is written");
// A fresh visit rather than cy.reload(): reloading the app inside the
// Cypress runner leaves the hub, landing on /unauthorized and then home.
cy.visit("/embedding/security");
cy.wait(["@getSettings", "@getSessionProperties"]);
cy.findAllByRole("switch").first().should("be.checked");
cy.request("GET", "/api/session/properties").then(({ body }) => {
expect(body["enable-embedding-modular"]).to.be.true;
});
});
it("lists published guest embeds even after guest embeds are switched off", () => {
cy.log("Publish a dashboard as a guest embed");
// Publishing is itself gated on guest embeds being on, so this has to
// come before the dashboard is marked embeddable.
H.updateSetting("enable-embedding-modular", true);
H.createDashboard({ name: "Published dashboard" }).then(
({ body: dashboard }) => {
cy.request("PUT", `/api/dashboard/${dashboard.id}`, {
enable_embedding: true,
});
},
);
cy.visit("/embedding/security");
assertPublishedDashboardIsListed();
cy.log(
"Turning guest embeds off is exactly when an admin needs to audit what is already out there",
);
H.updateSetting("enable-embedding-modular", false);
cy.visit("/embedding/security");
assertPublishedDashboardIsListed();
});
});
describe("oss", { tags: "@OSS" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.updateSetting("show-modular-embed-terms", false);
});
it("shows the Pro upsell banner and the CORS setting", () => {
cy.visit("/embedding/security");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByText(
"Upgrade to Metabase Pro to access the SDK for React and more advanced options.",
).should("be.visible");
// OSS leaves PLUGIN_ADMIN_SETTINGS.useUpsellFlow without a trigger, so
// UpsellCta renders the store link; paid builds get a button that
// opens the in-app flow instead.
cy.findByRole("link", { name: "Try Metabase Pro" }).should(
"be.visible",
);
cy.findByText("Cross-Origin Resource Sharing (CORS)").should("exist");
});
});
it("shows every hub tab, gemming the ones this plan lacks", () => {
cy.visit("/embedding/security");
assertHubTabs(TAB_GEMS_WITHOUT_PAID_FEATURES);
});
});
describe("starter", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("starter");
H.updateSetting("show-modular-embed-terms", false);
});
it("shows the Pro upsell banner and the CORS setting", () => {
cy.visit("/embedding/security");
cy.findByTestId("embedding-hub-main").within(() => {
// Starter is below Pro too -- SDK access is still gated.
cy.findByRole("button", { name: "Try Metabase Pro" }).should(
"be.visible",
);
cy.findByText("Cross-Origin Resource Sharing (CORS)").should("exist");
});
});
it("shows every hub tab, gemming the ones this plan lacks", () => {
cy.visit("/embedding/security");
assertHubTabs(TAB_GEMS_WITHOUT_PAID_FEATURES);
});
});
});
describe("scenarios > embedding > embedding hub > authentication", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("saves JWT settings from the hub's copy of the admin form", () => {
cy.intercept("PUT", "/api/setting").as("updateSettings");
cy.visit("/embedding/authentication");
cy.log("the hub supplies the heading, so the form does not add its own");
cy.findByRole("heading", { name: "Authentication" }).should("be.visible");
cy.findByRole("heading", { name: "JWT" }).should("not.exist");
cy.findByLabelText("JWT Identity Provider URI *")
.type("https://jwt.example.com/auth")
.blur();
cy.log("the shared secret is generated through the key modal");
cy.button("Set up key").click();
H.modal().within(() => {
cy.button("Create").click();
});
cy.button("Save and enable").click();
cy.wait("@updateSettings");
cy.log("the settings the form writes are the real ones");
cy.request("GET", "/api/session/properties").then(({ body }) => {
expect(body["jwt-enabled"]).to.equal(true);
expect(body["jwt-identity-provider-uri"]).to.equal(
"https://jwt.example.com/auth",
);
});
cy.log("and they survive a reload of the tab");
// cy.reload() does not work here: the app comes back without the hub
// around it, so the page renders with no "Authentication" heading and the
// form is never reached. Visiting the path again mounts the hub properly.
cy.visit("/embedding/authentication");
H.main()
.findByLabelText("JWT Identity Provider URI *")
.should("have.value", "https://jwt.example.com/auth");
});
it("sends the admin to Admin settings when only SAML is configured", () => {
configureSaml();
cy.visit("/embedding/authentication");
H.main().within(() => {
cy.findByText("SAML is configured").should("be.visible");
cy.findByLabelText("JWT Identity Provider URI *").should("not.exist");
});
});
});
describe("unlicensed", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("upsells instead of exposing the form", () => {
cy.visit("/embedding/authentication");
// Card copy is unit-tested next to the component; this is what needs a
// real browser -- the upsell rendering in place of the real form.
H.main().within(() => {
cy.findByText("Secure your embeds with single sign-on").should(
"be.visible",
);
cy.log("nothing configurable renders below the paywall");
cy.findByLabelText("JWT Identity Provider URI *").should("not.exist");
});
});
});
});
describe("scenarios > embedding > embedding hub > permissions", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("navigates every tab and level of the editor without leaving the hub", () => {
cy.visit("/embedding/permissions");
cy.log("The editor renders at the hub's path, not admin's");
cy.location("pathname").should("match", /^\/embedding\/permissions/);
walkThroughPermissionsEditor();
assertStillInsideHub();
});
it("navigates every tab and level of the editor with tenants enabled", () => {
// The tenant-only tabs (Shared collections, Tenant collections) only
// show up once tenants are on, so this is the full tab set -- the
// other test is the narrower one licensing alone shows.
H.updateSetting("use-tenants", true);
cy.visit("/embedding/permissions");
cy.log("The editor renders at the hub's path, not admin's");
cy.location("pathname").should("match", /^\/embedding\/permissions/);
walkThroughPermissionsEditor();
cy.log("Shared collections tab");
cy.findByRole("tab", { name: "Shared collections" }).click();
cy.findAllByRole("menuitem").first().click();
cy.location("pathname").should("match", /^\/embedding\/permissions/);
cy.log("Tenant collections tab");
cy.findByRole("tab", { name: "Tenant collections" }).click();
cy.findAllByRole("menuitem").first().click();
assertStillInsideHub();
});
});
});
function walkThroughPermissionsEditor() {
cy.log("Data tab, Groups view: lands here by default");
cy.findAllByRole("menuitem").first().click();
cy.findByTestId("permission-table").should("be.visible");
cy.log("Drilling into a group's database entity stays in the hub");
cy.findByTestId("permission-table").findByText("Sample Database").click();
cy.location("pathname").should("match", /^\/embedding\/permissions/);
cy.log("Data tab, Databases view");
cy.findByRole("tab", { name: "Databases" }).click();
cy.findAllByRole("menuitem").first().click();
cy.findByTestId("permission-table").should("be.visible");
cy.log("Collections tab");
cy.findByRole("tab", { name: "Collections" }).click();
cy.findAllByRole("menuitem").first().click();
cy.findByTestId("permission-table").should("be.visible");
cy.log("Application tab");
cy.findByRole("tab", { name: "Application" }).click();
cy.findByTestId("permission-table").should("be.visible");
}
function assertStillInsideHub() {
cy.log("Still inside the embedding hub throughout");
cy.location("pathname").should("match", /^\/embedding\/permissions/);
cy.findByTestId("embedding-hub-nav").should("be.visible");
}
describe("scenarios > embedding > embedding hub > tenancy", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("offers the enable card while the multi-tenant strategy is off", () => {
H.updateSetting("use-tenants", false);
cy.visit("/embedding");
cy.findByTestId("embedding-hub-nav")
.findByRole("link", { name: "Tenancy" })
.click();
cy.url().should("include", "/embedding/tenancy");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByText("Enable multi-tenant user strategy").should("be.visible");
cy.findByRole("tab", { name: "Tenants" }).should("not.exist");
});
});
it("carries the tenant surfaces once the strategy is on", () => {
H.updateSetting("use-tenants", false);
cy.visit("/embedding/tenancy");
cy.log("Enable multi-tenancy from the enable card, not the API");
cy.findByTestId("embedding-hub-main")
.findByRole("button", { name: "Enable multi-tenancy" })
.click();
cy.log("Enabling lands on the tenants listing, now empty");
// The empty state's copy is interpolated with anchor links, so no
// single node's text content matches the sentence exactly -- a regex
// matches the substring instead.
cy.findByTestId("embedding-hub-main")
.findByText(/Create your first tenant to start adding/)
.should("be.visible");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByRole("tab", { name: "Tenants" }).should("be.visible");
cy.findByRole("tab", { name: "Tenant users" }).click();
});
cy.log("The listing's own links stay inside the hub");
cy.url().should("include", "/embedding/tenancy/people");
cy.url().should("not.include", "/admin/people");
});
});
describe("oss", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("upsells rather than hiding the tab", () => {
cy.visit("/embedding/tenancy");
// Card copy is unit-tested next to the component; this just confirms
// the upsell renders in place of the real tenants surfaces.
cy.findByTestId("embedding-hub-main")
.findByText("Use a multi-tenant user strategy")
.should("be.visible");
});
});
});
describe("scenarios > embedding > embedding hub > appearance", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("carries the theme listing and the branding settings on one tab", () => {
cy.visit("/embedding");
cy.findByTestId("embedding-hub-nav")
.findByRole("link", { name: "Appearance" })
.click();
cy.url().should("include", "/embedding/appearance");
cy.findByTestId("embedding-hub-main").within(() => {
cy.findByRole("heading", { name: "Appearance" }).should("be.visible");
cy.findByRole("heading", { name: "Themes" }).should("be.visible");
cy.findByRole("heading", { name: "Branding elements" }).should(
"be.visible",
);
cy.findByText("Loading message").should("be.visible");
cy.findByText("When calculations return no results").should(
"be.visible",
);
cy.findByText("When no objects can be found").should("be.visible");
});
});
it("opens the theme editor inside the hub", () => {
cy.visit("/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByRole("button", { name: /New theme/ })
.click();
cy.url().should("include", "/embedding/appearance/theme/new");
cy.url().should("not.include", "/admin/embedding/themes");
});
it("opens an existing theme inside the hub", () => {
createThemeViaApi("Existing theme");
cy.visit("/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByText("Existing theme")
.click();
// The listing is the admin one, mounted with the hub's basePath: card
// clicks have to land here rather than on the admin route.
cy.url().should("match", /\/embedding\/appearance\/theme\/\d+/);
cy.url().should("not.include", "/admin/embedding/themes");
});
it("creates a theme from the hub and shows it in the listing", () => {
cy.visit("/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByRole("button", { name: /New theme/ })
.click();
cy.findByLabelText("Theme name").clear().type("Hub theme");
cy.findByRole("button", { name: /Save theme/ }).click();
H.undoToastList().contains("Theme saved").should("be.visible");
cy.url().should("eq", Cypress.config().baseUrl + "/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByText("Hub theme")
.should("be.visible");
});
});
describe("unlicensed", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("upsells rather than hiding the tab", () => {
cy.visit("/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByText("Create custom themes")
.should("be.visible");
});
it("sends a theme editor deep link back to the upsell", () => {
// A themeId, not the bare `theme` path -- that one redirects from the
// route table whatever the plan, so it would pass without the guard.
cy.visit("/embedding/appearance/theme/new");
cy.url().should("eq", Cypress.config().baseUrl + "/embedding/appearance");
cy.findByTestId("embedding-hub-main")
.findByText("Create custom themes")
.should("be.visible");
});
});
});
describe("scenarios > embedding > embedding hub > localization", () => {
describe("pro", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("mounts the dictionary configuration", () => {
cy.visit("/embedding/localization");
cy.findByTestId("embedding-hub-main")
.findByTestId("content-translation-configuration")
.findByText("Translate embedded dashboards and questions")
.should("be.visible");
});
});
describe("unlicensed", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("upsells rather than hiding the tab", () => {
cy.visit("/embedding/localization");
// Card copy is unit-tested next to the component; this just confirms
// the upsell renders in place of the real dictionary configuration.
cy.findByTestId("embedding-hub-main")
.findByText("Translate your embedded content")
.should("be.visible");
});
});
});
function configureSaml() {
cy.readFile("test_resources/sso/auth0-public-idp.cert", "utf8").then(
(certificate) => {
cy.request("PUT", "/api/setting", {
"saml-enabled": true,
"saml-identity-provider-uri": "https://example.test",
"saml-identity-provider-certificate": certificate,
"saml-identity-provider-issuer": "https://example.test/issuer",
});
},
);
}
function assertPublishedDashboardIsListed() {
cy.findByTestId("embedding-hub-main").within(() => {
// The hub clips its content and scrolls it internally, so this card sits
// below the fold on a CI-sized viewport and Cypress reads it as hidden
// until it is scrolled in.
cy.findByText("Published guest embeds")
.scrollIntoView()
.should("be.visible");
cy.findByText("Published dashboard").should("be.visible");
});
}

View File

@@ -6,14 +6,14 @@ import {
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
const standalonePath = "/embedding/security";
const standalonePath = "/admin/embedding/guest";
// These tests will run on both OSS and EE instances. Both without a token!
describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("show-sdk-embed-terms", false);
});
it("should not offer to share or embed models (metabase#20815)", () => {
@@ -39,14 +39,14 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
it("should show the sdk upsell link in oss", () => {
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
mainPage().within(() => {
cy.findByRole("link", { name: "Try Metabase Pro" })
cy.findByRole("link", { name: "Upgrade" })
.should("have.attr", "href")
.and(
"eq",
"https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-hub&utm_content=embedding-hub-security&source_plan=oss&utm_users=10",
"https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embedding-page&source_plan=oss&utm_users=10&utm_campaign=embedding-methods",
);
});
});
@@ -71,7 +71,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
};
["question", "dashboard"].forEach((object) => {
it(`should be able to publish/embed and then unpublish a ${object} without filters`, () => {
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});
const embeddableObject = object === "question" ? "card" : "dashboard";
@@ -181,10 +181,9 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
cy.visit(standalonePath);
cy.wait("@currentlyEmbeddedObject");
// The hub's Security tab renders this section only when published
// guest embeds exist, so unpublishing empties it rather than
// leaving a section with no rows.
mainPage().findByTestId("embedded-resources").should("not.exist");
mainPage()
.findAllByText(/No (questions|dashboards) have been embedded yet./)
.should("have.length", 2);
});
});
@@ -201,7 +200,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
enable_embedding: true,
});
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});
@@ -232,7 +231,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
it("should regenerate embedding token and invalidate previous embed url", () => {
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});
@@ -298,7 +297,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
function resetEmbedding() {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-static", false);
H.updateSetting("embedding-secret-key", null);
}
@@ -324,5 +323,5 @@ function visitAndEnableSharing(object, unpublishBeforeOpen = true) {
}
function mainPage() {
return cy.findByTestId("embedding-hub-main");
return cy.findByTestId("admin-layout-content");
}

View File

@@ -38,7 +38,7 @@ function createThemeViaApi(name = "Test theme") {
function visitThemeEditor(themeId: number) {
cy.intercept("GET", `/api/embed-theme/${themeId}`).as("getTheme");
cy.visit(`/embedding/appearance/theme/${themeId}`);
cy.visit(`/admin/embedding/themes/${themeId}`);
cy.wait("@getTheme");
}
@@ -70,8 +70,8 @@ describe(
cy.log("editor panel should show the theme name");
cy.findByLabelText("Theme name").should("have.value", "My custom theme");
cy.log("theme editor should render full width");
cy.findByTestId("embedding-hub-content-cap").should("not.exist");
cy.log("sidebar should be hidden on theme editor page");
cy.findByTestId("admin-layout-sidebar").should("not.exist");
cy.log("save button should be disabled when no changes");
cy.findByRole("button", { name: /Save theme/ }).should("be.disabled");
@@ -105,12 +105,12 @@ describe(
cy.findByRole("button", { name: /Cancel/ }).click();
cy.log("should navigate back to the themes listing");
cy.url().should("include", "/embedding/appearance");
cy.url().should("not.match", /\/theme\/\d+/);
cy.url().should("include", "/admin/embedding/themes");
cy.url().should("not.match", /\/themes\/\d+/);
});
it("shows not found for invalid theme id", () => {
cy.visit("/embedding/appearance/theme/99999");
cy.visit("/admin/embedding/themes/99999");
H.main().findByText("We're a little lost...").should("be.visible");
});
@@ -139,7 +139,7 @@ describe(
});
cy.log("should remain on the editor page after cancelling");
cy.url().should("match", /\/theme\/\d+/);
cy.url().should("match", /\/themes\/\d+/);
cy.log("confirm deletion");
cy.findByRole("button", { name: /Delete theme/ }).click();
@@ -150,8 +150,8 @@ describe(
H.undoToast().findByText("Theme deleted successfully").should("exist");
cy.log("should navigate back to the themes listing");
cy.url().should("include", "/embedding/appearance");
cy.url().should("not.match", /\/theme\/\d+/);
cy.url().should("include", "/admin/embedding/themes");
cy.url().should("not.match", /\/themes\/\d+/);
H.main().within(() => {
cy.findByText("Theme to delete").should("not.exist");
@@ -181,13 +181,13 @@ describe(
H.undoToast().findByText("Theme deleted successfully").should("exist");
cy.log("should land on the themes listing — not 404 or leave-prompt");
cy.url().should("include", "/embedding/appearance");
cy.url().should("not.match", /\/theme\/\d+/);
cy.url().should("include", "/admin/embedding/themes");
cy.url().should("not.match", /\/themes\/\d+/);
H.main().findByText("We're a little lost...").should("not.exist");
});
it("does not show the delete button when creating a new theme", () => {
cy.visit("/embedding/appearance/theme/new");
cy.visit("/admin/embedding/themes/new");
cy.findByLabelText("Theme name").should("be.visible");
cy.findByRole("button", { name: /Delete theme/ }).should("not.exist");
@@ -365,7 +365,7 @@ describe(
describe("preview panel", () => {
it("shows enable embedding prompt when embedding is not enabled", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-simple", false);
createThemeViaApi("Preview test").then((theme) => {
visitThemeEditor(theme.id);
@@ -384,8 +384,8 @@ describe(
});
it("shows theme preview when embedding is enabled", () => {
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
createThemeViaApi("Preview test").then((theme) => {
visitThemeEditor(theme.id);
@@ -398,8 +398,8 @@ describe(
describe("preview picker", () => {
beforeEach(() => {
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
});
it("defaults to a dashboard and can switch to a question", () => {

View File

@@ -20,7 +20,7 @@ describe(
});
it("lazily seeds Light and Dark on first visit, and preserves admin deletions across reloads", () => {
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().within(() => {
cy.log(
@@ -46,23 +46,23 @@ describe(
cy.findByRole("button", { name: /New theme/ }).click();
});
cy.url().should("match", /\/embedding\/appearance\/theme\/new$/);
cy.url().should("match", /\/admin\/embedding\/themes\/new$/);
});
it("does not create a theme when cancelling from the draft editor", () => {
cy.intercept("POST", "/api/embed-theme").as("createTheme");
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main()
.findByRole("button", { name: /New theme/ })
.click();
cy.url().should("match", /\/embedding\/appearance\/theme\/new$/);
cy.url().should("match", /\/admin\/embedding\/themes\/new$/);
cy.findByRole("button", { name: /Cancel/ }).click();
cy.log("navigates back to the listing");
cy.url().should("match", /\/embedding\/appearance$/);
cy.url().should("match", /\/admin\/embedding\/themes$/);
cy.log("new theme card is still visible");
H.main()
@@ -75,14 +75,14 @@ describe(
it("navigates to theme editor when clicking an existing theme card", () => {
createThemeViaApi("My theme");
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().within(() => {
cy.findByText("My theme").click();
});
cy.log("navigates to the theme editor page");
cy.url().should("match", /\/embedding\/appearance\/theme\/\d+/);
cy.url().should("match", /\/admin\/embedding\/themes\/\d+/);
});
it("uses white-labeled colors as a base for creating themes", () => {
@@ -98,7 +98,7 @@ describe(
H.updateSetting("application-colors", whitelabelColors);
cy.intercept("POST", "/api/embed-theme").as("createTheme");
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main()
.findByRole("button", { name: /New theme/ })
@@ -126,7 +126,7 @@ describe(
it("can duplicate a theme", () => {
createThemeViaApi("Untitled theme");
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().within(() => {
cy.findByText("Untitled theme").should("be.visible");
@@ -146,7 +146,7 @@ describe(
it("can delete a theme with confirmation", () => {
createThemeViaApi("Untitled theme");
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().findByText("Untitled theme").should("be.visible");

View File

@@ -8,13 +8,14 @@ describe("scenarios > embedding > themes > upsell", () => {
});
it("shows the upsell with Metabase Pro copy and an external upgrade link", () => {
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
cy.log("nav label has an upsell gem");
cy.findByRole("navigation", { name: "Embedding hub" })
.findByRole("link", { name: "Appearance" })
.findByTestId("upsell-gem")
.should("exist");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Themes/ })
.within(() => {
cy.icon("gem").should("be.visible");
});
H.main().within(() => {
cy.log("upsell copy matches the Figma");
@@ -54,13 +55,14 @@ describe("scenarios > embedding > themes > upsell", () => {
body: { available: false, plan_alias: "pro-cloud" },
});
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
cy.log("nav label has an upsell gem");
cy.findByRole("navigation", { name: "Embedding hub" })
.findByRole("link", { name: "Appearance" })
.findByTestId("upsell-gem")
.should("exist");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Themes/ })
.within(() => {
cy.icon("gem").should("be.visible");
});
H.main().within(() => {
cy.findByText("Metabase Pro").should("be.visible");
@@ -101,7 +103,7 @@ describe("scenarios > embedding > themes > upsell", () => {
body: { available: false, plan_alias: "pro-cloud" },
});
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().within(() => {
cy.findByText("Metabase Pro").should("be.visible");
@@ -113,7 +115,7 @@ describe("scenarios > embedding > themes > upsell", () => {
cy.findByText(/Please ask a Metabase Store Admin/).should("not.exist");
cy.log("no trial → no trial line");
cy.findByText(/14 day trial/).should("not.exist");
cy.findByText(/14-day free trial/).should("not.exist");
cy.log(
"CTA is rendered (hosted starter as store admin → opens the upgrade modal)",
@@ -143,7 +145,7 @@ describe("scenarios > embedding > themes > upsell", () => {
body: { available: true, plan_alias: "pro-cloud" },
});
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
H.main().within(() => {
cy.findByText("Metabase Pro").should("be.visible");
@@ -153,7 +155,7 @@ describe("scenarios > embedding > themes > upsell", () => {
cy.log("trial line is rendered");
cy.findByText(
/Get a 14 day trial of this and other Pro features/,
/Get a 14-day free trial of this and other pro features/,
).should("be.visible");
cy.log("button text switches to 'Try for free'");
@@ -171,13 +173,14 @@ describe("scenarios > embedding > themes > upsell", () => {
});
it("does not show the upsell and renders the themes listing", () => {
cy.visit("/embedding/appearance");
cy.visit("/admin/embedding/themes");
cy.log("nav label has no upsell gem");
cy.findByRole("navigation", { name: "Embedding hub" })
.findByRole("link", { name: "Appearance" })
.findByTestId("upsell-gem")
.should("not.exist");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Themes/ })
.within(() => {
cy.icon("gem").should("not.exist");
});
H.main().within(() => {
cy.log("upsell copy is absent");

View File

@@ -0,0 +1,32 @@
const { H } = cy;
describe("scenarios > modular embedding settings", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("should link to user strategy when tenants are disabled", () => {
cy.visit("/admin/embedding/modular");
H.main()
.findByText("Tenants")
.scrollIntoView()
.should("be.visible")
.closest("a")
.should("have.attr", "href", "/admin/people/user-strategy");
});
it("should link to tenants page when tenants are enabled", () => {
H.updateSetting("use-tenants", true);
cy.visit("/admin/embedding/modular");
H.main()
.findByText("Tenants")
.scrollIntoView()
.should("be.visible")
.closest("a")
.should("have.attr", "href", "/admin/people/tenants");
});
});

View File

@@ -5,7 +5,6 @@ import {
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import {
clickNewEmbedButton,
getEmbedSidebar,
navigateToEntitySelectionStep,
navigateToGetCodeStep,
@@ -22,8 +21,9 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
@@ -44,9 +44,7 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
});
H.modal().should("not.exist");
cy.findByTestId("embedding-hub-main")
.findByRole("heading", { name: "Security" })
.should("be.visible");
cy.findAllByTestId("sdk-setting-card").should("be.visible");
});
it("should close wizard when clicking `Done` button on the last step", () => {
@@ -65,9 +63,7 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
});
H.modal().should("not.exist");
cy.findByTestId("embedding-hub-main")
.findByRole("heading", { name: "Security" })
.should("be.visible");
cy.findAllByTestId("sdk-setting-card").should("be.visible");
});
it("should close wizard when navigating back in browser history", () => {
@@ -76,12 +72,14 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
.contains("General")
.should("be.visible");
cy.visit("/embedding/security");
cy.findByTestId("embedding-hub-main")
.findByRole("heading", { name: "Security" })
.should("be.visible");
cy.visit("/admin/embedding");
cy.findAllByTestId("sdk-setting-card").should("be.visible");
clickNewEmbedButton();
cy.findAllByTestId("sdk-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.wait("@dashboard");
@@ -98,8 +96,8 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
});
describe("auth type switch", () => {
it("allows to select the `guest` item even when modular embedding is disabled", () => {
H.updateSetting("enable-embedding-modular", false);
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
H.updateSetting("enable-embedding-static", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
@@ -108,8 +106,8 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
cy.findByLabelText("Guest").should("be.enabled");
});
it("allows to select the `Metabase Account` item even when modular embedding is disabled", () => {
H.updateSetting("enable-embedding-modular", false);
it("allows to select the `Metabase Account` item even when simple embedding setting is disabled", () => {
H.updateSetting("enable-embedding-simple", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
@@ -129,9 +127,22 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
.click();
};
const openFromEmbeddingHub = () => {
cy.visit("/embedding/security");
clickNewEmbedButton();
const openFromAdminEmbedding = () => {
cy.visit("/admin/embedding");
cy.findAllByTestId("sdk-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
};
const openFromAdminGuestEmbeds = () => {
cy.visit("/admin/embedding/guest");
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
};
const openFromSharingMenu = () => {
@@ -158,22 +169,30 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
openFromCommandPalette();
assertCheckedAuth("sso");
openFromEmbeddingHub();
openFromAdminEmbedding();
assertCheckedAuth("sso");
openFromSharingMenu();
assertCheckedAuth("sso");
// The Guest embeds admin section is intentionally guest-only and
// forces guest mode regardless of SSO configuration.
openFromAdminGuestEmbeds();
assertCheckedAuth("guest");
});
it("defaults to Guest from all entry points when SSO is not configured", () => {
openFromCommandPalette();
assertCheckedAuth("guest");
openFromEmbeddingHub();
openFromAdminEmbedding();
assertCheckedAuth("guest");
openFromSharingMenu();
assertCheckedAuth("guest");
openFromAdminGuestEmbeds();
assertCheckedAuth("guest");
});
});

View File

@@ -26,7 +26,7 @@ describe("scenarios > embedding > sdk iframe embed setup > common (oss and start
});
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-static", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
@@ -36,7 +36,7 @@ describe("scenarios > embedding > sdk iframe embed setup > common (oss and start
});
it("does not allow to select the `Metabase Account`, when token feature is missing (oss)", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-simple", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);

View File

@@ -4,30 +4,32 @@ import {
openSharingMenu,
} from "e2e/support/helpers";
import { clickNewEmbedButton, getEmbedSidebar } from "./helpers";
import { getEmbedSidebar } from "./helpers";
const { H } = cy;
const DATA_BY_EMBEDDING_TYPE = {
guest: {
path: "/embedding/security",
path: "/admin/embedding/guest",
token: null,
authMethodLabel: "Guest",
cardTestId: "guest-embeds-setting-card",
cardText:
"To continue, enable embedding and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-modular",
"To continue, enable guest embeds and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-static",
showTermsSettingName: "show-static-embed-terms",
tooltipText:
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
},
modular: {
path: "/embedding/security",
path: "/admin/embedding",
token: "bleeding-edge",
authMethodLabel: "Metabase account (SSO)",
cardTestId: "sdk-setting-card",
cardText:
"To continue, enable modular embedding and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-modular",
showTermsSettingName: "show-modular-embed-terms",
embeddingSettingName: "enable-embedding-simple",
showTermsSettingName: "show-simple-embed-terms",
tooltipText: /Sharing Metabase accounts is a security risk/,
},
} as const;
@@ -48,6 +50,7 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (EE)"
authMethodLabel,
embeddingSettingName,
showTermsSettingName,
cardTestId,
cardText,
tooltipText,
} = value;
@@ -64,7 +67,11 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (EE)"
cy.visit(path);
clickNewEmbedButton();
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
@@ -120,7 +127,11 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (EE)"
cy.visit(path);
clickNewEmbedButton();
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
@@ -145,7 +156,11 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (EE)"
cy.visit(path);
clickNewEmbedButton();
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
@@ -155,7 +170,7 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (EE)"
});
it("shows guest embed status bar when guest embedding is toggled from disabled to enabled state", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-static", false);
H.visitDashboard(ORDERS_DASHBOARD_ID);

View File

@@ -1,6 +1,6 @@
import { embedModalEnableEmbeddingCard } from "e2e/support/helpers";
import { clickNewEmbedButton, getEmbedSidebar } from "./helpers";
import { getEmbedSidebar } from "./helpers";
const { H } = cy;
@@ -23,16 +23,20 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss
describe("guest", () => {
it("shows the Enable to Continue button and enables embedding on click", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("enable-embedding-static", false);
H.updateSetting("show-static-embed-terms", true);
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
clickNewEmbedButton();
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().should(
"contain.text",
"To continue, enable embedding and agree to the usage conditions.",
"To continue, enable guest embeds and agree to the usage conditions.",
);
embedModalEnableEmbeddingCard().within(() => {
@@ -95,12 +99,16 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss
});
it("shows the enable card with fair usage terms when embedding is already enabled", () => {
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", true);
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
clickNewEmbedButton();
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard()
.should("contain.text", "Agree to the")
@@ -116,7 +124,7 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss
.should("have.attr", "target", "_blank");
cy.findByText(
"To continue, enable embedding and agree to the usage conditions.",
/To continue, enable guest embeds and agree to the/,
).should("not.exist");
});
@@ -131,16 +139,20 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss
});
it("hides the enable card when embedding is already enabled", () => {
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
clickNewEmbedButton();
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
getEmbedSidebar()
.contains(
"To continue, enable embedding and agree to the usage conditions.",
"To continue, enable guest embeds and agree to the usage conditions.",
)
.should("not.exist");
});
@@ -148,16 +160,20 @@ describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss
describe("Metabase account (sso)", () => {
it("does not show the Enable to Continue button and disables item", () => {
H.updateSetting("enable-embedding-modular", false);
H.updateSetting("show-modular-embed-terms", true);
H.updateSetting("enable-embedding-simple", false);
H.updateSetting("show-simple-embed-terms", true);
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
clickNewEmbedButton();
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(
"To continue, enable embedding and agree to the usage conditions.",
"To continue, enable guest embeds and agree to the usage conditions.",
).should("not.exist");
});

View File

@@ -19,7 +19,7 @@ describe(suiteTitle, () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("embedding-secret-key", JWT_SHARED_SECRET);
H.mockEmbedJsToDevServer();
});

View File

@@ -26,8 +26,9 @@ describe(suiteTitle, () => {
// guest mode while SSO is unconfigured, so an unaccepted guest CTA would
// otherwise have to be clicked through first. That flow is covered by
// embed-flow-enable-embed-js-*.
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
H.mockEmbedJsToDevServer();
});

View File

@@ -21,8 +21,9 @@ describe("scenarios > embedding > sdk iframe embed setup > get code step", () =>
H.enableTracking();
// Accept the embedding terms up front so the wizard never shows the
// Agree CTA — its flow is covered by embed-flow-enable-embed-js-*.
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("show-modular-embed-terms", false);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");

View File

@@ -24,18 +24,18 @@ export const getResourceSelectorButton = (
options?: Partial<Cypress.Timeoutable>,
) => cy.findByTestId("embed-browse-entity-button", options);
// "New embed" is a button in the embedding hub's nav.
export const clickNewEmbedButton = () =>
cy.findByRole("button", { name: "New embed" }).click();
export const visitNewEmbedPage = (
{ waitForResource } = { waitForResource: true },
) => {
cy.intercept("GET", "/api/dashboard/*").as("dashboard");
cy.visit("/embedding/security");
cy.visit("/admin/embedding");
clickNewEmbedButton();
cy.findAllByTestId(/(sdk-setting-card|guest-embeds-setting-card)/)
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.get("body").then(() => {
if (waitForResource) {

View File

@@ -32,7 +32,7 @@ describe(suiteTitle, () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
@@ -291,7 +291,7 @@ describe("recently created dashboards", () => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
cy.intercept("GET", "/api/activity/recents?*").as("recentActivity");

View File

@@ -24,7 +24,8 @@ describe(suiteTitle, () => {
H.activateToken("pro-self-hosted");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("enable-embedding-static", true);
H.setupAnthropicLlmProvider();
cy.intercept("GET", "/api/dashboard/*").as("dashboard");
@@ -174,13 +175,12 @@ describe(suiteTitle, () => {
cy.visit(`/question/${ORDERS_QUESTION_ID}`);
H.openEmbedJsModal();
H.embedModalEnableEmbedding();
cy.get("#iframe-embed-container")
.findByTestId("preview-loading-indicator", { timeout: 20_000 })
.should("be.visible");
H.embedModalEnableEmbedding();
cy.get("[data-iframe-loaded]", { timeout: 20_000 }).should(
"have.length",
1,
@@ -198,13 +198,6 @@ describe(suiteTitle, () => {
visitNewEmbedPage();
// The hub's "New embed" button sits in the nav and is clickable as soon as
// the layout mounts, where the admin settings card it replaced only
// appeared once that page had loaded its settings. That delay was giving
// the throttled recents response a head start; without it the wizard is
// still resolving when the assertions run.
cy.wait("@getRecents");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Person overview").should("not.exist");
cy.findByText("Orders in a dashboard").should("be.visible");

View File

@@ -33,7 +33,7 @@ describe("OSS", { tags: "@OSS" }, () => {
H.resetSnowplow();
cy.signInAsAdmin();
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
@@ -69,7 +69,7 @@ describe("EE without license", () => {
cy.signInAsAdmin();
H.activateToken("starter");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
@@ -95,7 +95,7 @@ describe(suiteTitle, () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.enableTracking();
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
H.setupAnthropicLlmProvider();
cy.intercept("GET", "/api/dashboard/**").as("dashboard");

View File

@@ -1,4 +1,4 @@
import { clickNewEmbedButton, navigateToEmbedOptionsStep } from "./helpers";
import { navigateToEmbedOptionsStep } from "./helpers";
const { H } = cy;
@@ -33,12 +33,20 @@ const assertPreviewFinishesLoading = () => {
cy.findByTestId("preview-loading-indicator").should("not.exist");
};
const reopenNewEmbedModal = () => {
cy.findAllByTestId(/(sdk-setting-card|guest-embeds-setting-card)/)
.first()
.within(() => {
cy.findByText("New embed").click();
});
};
describe("scenarios > embedding > sdk iframe embed setup > user settings persistence", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.updateSetting("enable-embedding-modular", true);
H.updateSetting("enable-embedding-simple", true);
cy.intercept("PUT", "/api/setting/sdk-iframe-embed-setup-settings").as(
"persistSettings",
@@ -111,7 +119,7 @@ describe("scenarios > embedding > sdk iframe embed setup > user settings persist
cy.findByTestId("sdk-iframe-embed-setup-modal-content").should("not.exist");
cy.log("reopen the New embed modal");
clickNewEmbedButton();
reopenNewEmbedModal();
cy.log(
"the preview must finish loading — the restored saved theme must not leave it stuck on the loader",

View File

@@ -256,10 +256,10 @@ describe("scenarios > embedding > sdk iframe embedding > internal-navigation", (
it("should not support internal navigation on guest embeds", () => {
// Enable guest embedding settings
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-simple", {
value: true,
});
cy.request("PUT", "/api/setting/enable-embedding-modular", {
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});
updateSetting("embedding-secret-key", JWT_SHARED_SECRET);

View File

@@ -14,24 +14,24 @@ describe("scenarios - setup guide", () => {
H.activateToken("pro-cloud");
});
it("Contains the setup guide in the hub nav", () => {
cy.visit("/embedding/security");
it("Contains setup guide in sidebar", () => {
cy.visit("/admin/embedding");
cy.findByRole("navigation", { name: "Embedding hub" })
.findByRole("link", { name: "Get started" })
cy.findByTestId("admin-layout-sidebar")
.findByText("Setup guide")
.should("exist")
.click();
cy.findByTestId("embedding-hub-main")
.findByRole("heading", { name: "Get started with Metabase Embedding" })
cy.findByTestId("admin-layout-content")
.findByRole("heading", { name: "Embedding setup guide" })
.should("exist");
});
it('"Create a dashboard" card should save the x-ray and show a success toast without leaving the guide', () => {
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.log("Find and click on 'Create a dashboard' card");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Create a dashboard")
.click();
@@ -50,14 +50,14 @@ describe("scenarios - setup guide", () => {
H.undoToast().findByText("See it").should("be.visible");
cy.log("Should remain on the setup guide");
cy.url().should("include", "/embedding/get-started");
cy.url().should("include", "/admin/embedding/setup-guide");
});
it('"Connect a database" card should pass from param in navigation URL', () => {
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.log("Find and click on 'Connect a database' card");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.click();
@@ -75,7 +75,7 @@ describe("scenarios - setup guide", () => {
});
it("Uploading CSVs to sample database should mark the 'Add Data' step as done", () => {
cy.intercept("GET", "/api/embedding-hub/checklist").as("getChecklist");
cy.intercept("GET", "/api/ee/embedding-hub/checklist").as("getChecklist");
cy.log("Enable CSV uploads");
cy.request("PUT", "/api/setting/uploads-settings", {
@@ -86,16 +86,16 @@ describe("scenarios - setup guide", () => {
},
});
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.log("'Connect a database' should not be marked as done");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.closest('[data-testid="embedding-hub-checklist-card"]')
.findByLabelText("Step 1 complete")
.closest("button")
.findByText("Done")
.should("not.exist");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.click();
@@ -122,18 +122,18 @@ describe("scenarios - setup guide", () => {
cy.wait("@getChecklist");
cy.log("'Connect a database' should be marked as done");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.closest('[data-testid="embedding-hub-checklist-card"]')
.closest("button")
.scrollIntoView()
.findByLabelText("Step 1 complete")
.findByText("Done")
.should("be.visible");
});
it('"Get embed snippet" card should take you to the embed flow', () => {
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Get embed snippet")
.click();
@@ -148,17 +148,17 @@ describe("scenarios - setup guide", () => {
cy.log("Create a dashboard to embed");
H.createDashboard({ name: "Test Dashboard" });
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.log("step should not be marked as done at first");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Get embed snippet")
.closest('[data-testid="embedding-hub-checklist-card"]')
.findByLabelText("Step 3 complete")
.closest("button")
.findByText("Done")
.should("not.exist");
cy.log("open embed wizard");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Get embed snippet")
.click();
@@ -189,10 +189,10 @@ describe("scenarios - setup guide", () => {
H.modal().first().findByLabelText("Close").click();
cy.log("step should be marked as done");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Get embed snippet")
.closest('[data-testid="embedding-hub-checklist-card"]')
.findByLabelText("Step 3 complete", { timeout: 10_000 })
.closest("button")
.findByText("Done", { timeout: 10_000 })
.should("be.visible");
});
@@ -271,14 +271,45 @@ describe("scenarios - setup guide", () => {
.should("not.exist");
});
it('"Configure data permissions and tenants" card should navigate to permissions onboarding page', () => {
cy.visit("/embedding/get-started");
it("should link to user strategy when tenants are disabled", () => {
H.restore("setup");
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
cy.findByTestId("embedding-hub-main")
.findByText("Configure data permissions and tenants")
cy.visit("/admin/embedding/setup-guide");
H.main()
.findByText("Tenants")
.scrollIntoView()
.should("be.visible")
.closest("a")
.should("have.attr", "href", "/admin/people/user-strategy");
});
it("should link to tenants page when tenants are enabled", () => {
H.restore("setup");
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.updateSetting("use-tenants", true);
cy.visit("/admin/embedding/setup-guide");
H.main()
.findByText("Tenants")
.scrollIntoView()
.should("be.visible")
.closest("a")
.should("have.attr", "href", "/admin/people/tenants");
});
it('"Configure data permissions and enable tenants" card should navigate to permissions onboarding page', () => {
cy.visit("/admin/embedding/setup-guide");
cy.findByTestId("admin-layout-content")
.findByText("Configure data permissions and enable tenants")
.click();
cy.url().should("include", "/embedding/get-started/permissions");
cy.url().should("include", "/admin/embedding/setup-guide/permissions");
H.main()
.findByText("Configure data permissions and enable tenants")
@@ -291,7 +322,7 @@ describe("scenarios - setup guide", () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("all 5 steps are present and none are completed at first");
H.main().within(() => {
@@ -352,9 +383,9 @@ describe("scenarios - setup guide", () => {
H.activateToken("pro-self-hosted");
cy.log("create an x-ray dashboard via the embedding setup guide");
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Create a dashboard")
.click();
@@ -368,7 +399,7 @@ describe("scenarios - setup guide", () => {
cy.log("wait for x-ray dashboard to generate and save it");
H.undoToast().should("contain", "Your dashboard was saved");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("tenants should not be enabled");
cy.request("GET", "/api/session/properties").then((response) => {
@@ -473,7 +504,7 @@ describe("scenarios - setup guide", () => {
cy.log("enable tenants via setting without creating a shared collection");
H.updateSetting("use-tenants", true);
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("no steps should be completed");
H.main().within(() => {
@@ -504,7 +535,7 @@ describe("scenarios - setup guide", () => {
namespace: "shared-tenant-collection",
});
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("wait until step is marked as complete");
H.main().icon("check").should("have.length", 1);
@@ -525,7 +556,7 @@ describe("scenarios - setup guide", () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("click the enable tenants button");
H.main()
@@ -584,7 +615,7 @@ describe("scenarios - setup guide", () => {
});
it("can create two tenants and show summary", () => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("step 1 should be marked as done before navigating");
H.main()
@@ -668,11 +699,11 @@ describe("scenarios - setup guide", () => {
});
cy.log("Configure data permissions step should be done");
cy.findByTestId("embedding-hub-main")
.findByText("Configure data permissions and tenants")
.closest('[data-testid="embedding-hub-checklist-card"]')
cy.findByTestId("admin-layout-content")
.findByText("Configure data permissions and enable tenants")
.closest("button")
.scrollIntoView()
.findByLabelText("Step 4 complete")
.findByText("Done")
.should("be.visible");
cy.log("verify tenant_attributes are saved correctly via API");
@@ -714,7 +745,7 @@ describe("scenarios - setup guide", () => {
slug: "existing-tenant",
});
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
H.main()
.findByRole("listitem", { name: "Create tenants" })
@@ -748,7 +779,7 @@ describe("scenarios - setup guide", () => {
});
it("reloads with strategy pre-selected and 'Select data' step unlocked when RLS is configured", () => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log(
"'Select data' step should not be locked when RLS is configured",
@@ -774,7 +805,7 @@ describe("scenarios - setup guide", () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("enable tenants and create shared collection");
H.main()
@@ -837,7 +868,7 @@ describe("scenarios - setup guide", () => {
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("enable tenants and create shared collection");
H.main()
@@ -924,7 +955,7 @@ describe("scenarios - setup guide", () => {
"updatePermissionsGraph",
);
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("steps 3, 4, and 5 should be locked initially");
H.main().within(() => {
@@ -1147,7 +1178,7 @@ describe("scenarios - setup guide", () => {
},
);
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("wait for checklist data to load before interacting with steps");
H.main()
@@ -1259,7 +1290,7 @@ describe("scenarios - setup guide", () => {
"updatePermissionsGraph",
);
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("enable tenants and create shared collection");
H.main()
@@ -1361,22 +1392,26 @@ describe("scenarios - setup guide", () => {
);
it("locks the production embed step when JWT is not enabled", () => {
cy.visit("/embedding/get-started");
cy.visit("/admin/embedding/setup-guide");
cy.log("jwt should be disabled by default");
cy.request("GET", "/api/session/properties").then(({ body }) => {
expect(body["jwt-enabled"]).to.equal(false);
});
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Embed in production with SSO")
.scrollIntoView()
.should("be.visible")
.closest('[data-testid="embedding-hub-checklist-card"]')
.should("have.attr", "aria-disabled", "true")
.realHover();
.closest("button")
.icon("lock")
.should("be.visible");
cy.findByRole("tooltip").should("have.text", "Set up SSO to unlock");
cy.findByTestId("admin-layout-content")
.findByText("Embed in production with SSO")
.closest("button")
.findByText("Complete the other steps to unlock")
.should("be.visible");
});
});
@@ -1403,7 +1438,7 @@ describe("scenarios - setup guide", () => {
H.addPostgresDatabase("QA Postgres12");
cy.get<number>("@postgresID").then((postgresId) => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
H.main()
.findByRole("radio", { name: /Connection impersonation/ })
@@ -1468,7 +1503,7 @@ describe("scenarios - setup guide", () => {
});
it("should show 'no compatible databases' message when only Sample Database exists", () => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("select connection impersonation strategy");
H.main()
@@ -1496,7 +1531,7 @@ describe("scenarios - setup guide", () => {
it("should show disabled database with tooltip when database does not support connection impersonation", function () {
H.addPostgresDatabase("QA Postgres12");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
H.main()
.findByRole("radio", { name: /Connection impersonation/ })
@@ -1550,7 +1585,7 @@ describe("scenarios - setup guide", () => {
});
it("creates a tenant with database_role attribute when using connection impersonation", () => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("select connection impersonation strategy");
H.main()
@@ -1618,9 +1653,9 @@ describe("scenarios - setup guide", () => {
cy.intercept("PUT", "/api/permissions/graph").as(
"updatePermissionsGraph",
);
cy.intercept("GET", "/api/embedding-hub/checklist").as("getChecklist");
cy.intercept("GET", "/api/ee/embedding-hub/checklist").as("getChecklist");
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log(
"wait for checklist to load and page to settle on the summary step",
@@ -1710,7 +1745,7 @@ describe("scenarios - setup guide", () => {
});
it("creates a tenant with database_slug attribute when using database routing", () => {
cy.visit("/embedding/get-started/permissions");
cy.visit("/admin/embedding/setup-guide/permissions");
cy.log("select database routing strategy");
H.main()
@@ -1768,7 +1803,7 @@ describe("scenarios - setup guide", () => {
});
it("disables the Enable JWT button when IdP URI is empty", () => {
cy.visit("/embedding/get-started/sso");
cy.visit("/admin/embedding/setup-guide/sso");
cy.findByLabelText(/JWT Identity Provider URI/i)
.should("be.visible")
@@ -1783,7 +1818,7 @@ describe("scenarios - setup guide", () => {
});
it("can configure JWT auth and complete SSO setup", () => {
cy.visit("/embedding/get-started/sso");
cy.visit("/admin/embedding/setup-guide/sso");
cy.log("no steps should be completed initially");
H.main().icon("check").should("not.exist");
@@ -1841,24 +1876,25 @@ describe("scenarios - setup guide", () => {
});
cy.log("should go back to setup guide");
cy.url().should("include", "/embedding/get-started");
cy.url().should("include", "/admin/embedding/setup-guide");
cy.url().should("not.include", "/sso");
cy.log("'Configure SSO' card should be marked as done");
cy.findByTestId("embedding-hub-main")
.findByText("Set up SSO")
.closest('[data-testid="embedding-hub-checklist-card"]')
cy.findByTestId("admin-layout-content")
.findByText("Configure SSO")
.closest("button")
.scrollIntoView()
.findByLabelText("Step 5 complete", { timeout: 10_000 })
.findByText("Done", { timeout: 10_000 })
.should("be.visible");
cy.log("'Embed in production with SSO' should now be unlocked");
cy.findByTestId("embedding-hub-main")
cy.findByTestId("admin-layout-content")
.findByText("Embed in production with SSO")
.scrollIntoView()
.should("be.visible")
.closest('[data-testid="embedding-hub-checklist-card"]')
.should("not.have.attr", "aria-disabled");
.closest("button")
.icon("lock")
.should("not.exist");
});
});
@@ -1874,7 +1910,7 @@ describe("scenarios - setup guide", () => {
"jwt-shared-secret": "0".repeat(64),
});
cy.visit("/embedding/get-started/sso");
cy.visit("/admin/embedding/setup-guide/sso");
cy.log("step 1 should be marked as done");
H.main()

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More