feat: pre-approved cloud accounts: login links, OAuth adoption, setup, and the trial bridge (#10875)

* feat: single-use login links and oauth-claimable pending accounts

* docs: capture the auth surface facts behind login links

* fix: accept stringified email_verified from oauth userinfo

* docs: describe the oauth claim rule in the auth surface notes

* fix: harden login-link redirects and sweep expired links

* chore: bump ee-repo-ref

* fix: keep expired login links a day so an open still reads as expired

* fix: refuse login links for superadmin and devops accounts

* fix: re-check the account's roles when a login link is opened

* feat: pre-approved cloud accounts finish their setup and start their trial from Windmill

* feat: dev-only localStorage opt-in to the cloud UI on localhost

* feat: finish-setup entry in the desktop settings menu

* style: pulse the settings row while account setup is pending; shorter, blue finish-setup entry

* fix: list the configured providers in the finish-setup modal

* fix: open the finish-setup modal after the menu has closed

* feat: finish-setup provider sign-in keeps the session when the provider asserts another address

* chore: pin the EE companion commit

* fix: plain toast for the finish-setup refusal

* style: format the dev cloud override

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* feat: onboarding skips the source question an invite already answered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: type the finish-setup icons and login_type as the frontend uses them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* feat: invited accounts get a workspace name, hub picks and starter prompts from their invite

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: the workspace form reads the invite's name itself, so the picker prefills it too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* feat: an empty workspace offers the projects its invite picked, one click from importing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* style: picked projects get identical import buttons

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* feat: a pinned sidebar banner until an invited account has credentials of its own

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* style: the account-setup row speaks the rail's language, tinted not filled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* chore: pin ee-repo-ref to the import fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* refactor: picked projects live in the template picker only; account-setup row moves to the rail footer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: review round — no portal login for job tokens, finish-setup failures keep the session, prompt labels deduped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: CI round — trial start is a POST, profile cache follows the session, setup row on MenuButton

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: CI round — no password road where password login is off, cache note on the login form, trial refusal surfaced

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: CI round — set_password guarded on its read, refusal stays on the page, docs and formatting

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: CI round — popup OAuth clears the profile cache, portal helper crate-private, refusal toast stays

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: a refused trial is recorded inline in the rail, not in a day-long toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: the refusal notice uses the rail's button and has a collapsed form

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: CI round — SSO can finish account setup, with the same mismatch refusal as OAuth

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: SSO finish-setup rides in RelayState and the refusal notice is a status region

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: keep the finish-setup cookie beside RelayState, hoist the status region, pin session-keyed profile cache

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: empty live region for the trial refusal, drop the setup cookie once adopted, telemetry inventory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: the trial refusal survives the responsive sidebar swap

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: the trial refusal is shown to the account it answers, modal open prop is required

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* feat: an invited account skips the whole onboarding survey

Its source is the invite and its use case was researched before the invite went
out, so neither question is asked: the known source is recorded and onboarding
opens on naming the workspace. Accounts without an invite profile see the survey
exactly as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: an invited account with a workspace leaves onboarding before anything paints

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: account-setup state resets on sign-out, onboarding shows a loading state while it settles

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* style: keep the refresh doc comment on refresh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* fix: profile lists are distinct, and the offer table notes what a users-import does to it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HMniEf5hapoKEB6TEBcGy

* chore: update ee-repo-ref to 1ba6fe83451f0a1f8fafe04b7187087d51e0f769

This commit updates the EE repository reference after PR #750 was merged in windmill-ee-private.

Previous ee-repo-ref: be42722d09832ffff709a1f710f3e97e34d513b2

New ee-repo-ref: 1ba6fe83451f0a1f8fafe04b7187087d51e0f769

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2026-09-14 20:04:58 +00:00
committed by GitHub
co-authored by Claude Fable 5.1 windmill-internal-app[bot]
parent 0b1e9c0dda
commit 91e6dc39ce
52 changed files with 2244 additions and 47 deletions
+3
View File
@@ -30,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
Read before designing anything that creates users, tokens or sessions.
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO login_link (token_hash, email, rd, expiration, created_by)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bpchar",
"Varchar",
"Text",
"Timestamptz",
"Varchar"
]
},
"nullable": []
},
"hash": "071de805623be166dddd2655f099bed4ebbf6a03ec5988acf072c83818d57a02"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "25f27dba5c0ea81d9412bdf1986c2a38b2dcf3976fbd67522c4644ee9bddc330"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET password_hash = $1, login_type = 'password'\n WHERE email = $2 AND login_type = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "310d91848c7a032846aa8be8c5e5f42477fc5ab17fac70864d1b2f7f91ac7f9d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT profile FROM cloud_onboarding_profile WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "profile",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "42783d94ee41c5b17ec16b480dd55af2a0ba2e827ae8add59d5e5465dc1d5743"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO password(email, verified, password_hash, login_type, super_admin, name, company, username, first_time_user)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "4ed69ae9e2a0d045ec63e327bc40c73aba9b34302e2f25cd6928a29d974bbb2c"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin, devops FROM password WHERE email = $1 AND disabled = false FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "devops",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "5bd410d777a7a6d48129e9fee8402455082e0a172948be6441e5383552331c3f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM cloud_trial_offer WHERE email = $1 AND consumed_at IS NULL)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5ca0afc5a7b0437de221c8cc7b31e015ec604eb92a40b8649d054cabef1d8060"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cloud_onboarding_profile (email, profile, created_by) VALUES ($1, $2, $3)\n ON CONFLICT (email) DO UPDATE SET profile = EXCLUDED.profile",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "64bc01a5d88680febabd794b6472b6d22720d71e555501011e1d6b3418064ed0"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE login_link SET consumed_at = now()\n WHERE token_hash = $1 AND consumed_at IS NULL AND expiration > now()\n RETURNING email, rd, created_by",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "rd",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Bpchar"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "754598696e57a8c3ee6477d4f55f62e12019aa9582f06b9a47f7d04715eee24c"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin, devops, login_type FROM password WHERE email = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "login_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "a2be5aeb7e663b0fe403726b4a41a7760b21c7edb651452c80b46b54ec964901"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT consumed_at IS NOT NULL AS \"used!\" FROM login_link WHERE token_hash = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "used!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Bpchar"
]
},
"nullable": [
null
]
},
"hash": "ab16363a5225b022c7262f3caf5cd21ed1fbcc989fcc020c8246d5e2a313b72c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cloud_trial_offer (email, created_by) VALUES ($1, $2)\n ON CONFLICT (email) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "b2855a7bf20ec5a405d8c059e7b3b635507f545a514ff0d9142c663b779dd961"
}
+1 -1
View File
@@ -1 +1 @@
a4da009a5eae72bd55f34de41ba7929b53d53c9b
1ba6fe83451f0a1f8fafe04b7187087d51e0f769
@@ -0,0 +1 @@
DROP TABLE IF EXISTS login_link;
@@ -0,0 +1,13 @@
-- Single-use login links minted by a superadmin for one account. Consumed by an
-- unauthenticated GET that mints a session; the row is never a bearer credential itself.
CREATE TABLE login_link (
token_hash CHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
rd TEXT,
expiration TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX login_link_email_idx ON login_link (email);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cloud_trial_offer;
@@ -0,0 +1,11 @@
-- A pre-approved self-hosted Enterprise trial offered to an account created through a
-- pre-approved invite. No expiry: the offer lasts until a trial or subscription exists.
-- The cascade follows the account out on deletion and rename. A superadmin users-import
-- replaces every account by deleting and reinserting it, which takes these rows with it:
-- the offers, like the onboarding profiles, are recorded by the portal that minted them.
CREATE TABLE cloud_trial_offer (
email VARCHAR(255) PRIMARY KEY REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
consumed_at TIMESTAMPTZ,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cloud_onboarding_profile;
@@ -0,0 +1,9 @@
-- Context an invite carried about the account's owner, written at provisioning and read by
-- onboarding to tailor itself (skip the source question it knows the answer to, later
-- template picks and starter prompts). Free-form JSON so new fields need no migration.
CREATE TABLE cloud_onboarding_profile (
email VARCHAR(255) PRIMARY KEY REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
profile JSONB NOT NULL,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+17
View File
@@ -1784,6 +1784,23 @@ pub async fn delete_expired_items(db: &DB) -> () {
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
}
let expired_login_links_r: std::result::Result<Vec<String>, _> =
// Expired rows stay a day so an open still reports "expired" rather than "invalid".
sqlx::query_scalar(
"DELETE FROM login_link WHERE expiration <= now() - interval '1 day' RETURNING token_hash",
)
.fetch_all(db)
.await;
match expired_login_links_r {
Ok(hashes) => {
if !hashes.is_empty() {
tracing::info!("deleted {} expired login links", hashes.len())
}
}
Err(e) => tracing::error!("Error deleting login links: {}", e.to_string()),
}
let pip_resolution_r = sqlx::query_scalar!(
"DELETE FROM pip_resolution_cache WHERE expiration <= now() RETURNING hash",
)
@@ -0,0 +1,192 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let mint = |token: &'static str, body: serde_json::Value| {
client()
.post(format!("{base}/users/login_links"))
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
};
// Only a superadmin mints.
let resp = mint("SECRET_TOKEN_2", json!({"email": "test2@windmill.dev"})).await?;
assert_eq!(resp.status(), 401);
// A superadmin account is never a valid target: the minting credential must not
// become an instance-wide role.
let resp = mint("SECRET_TOKEN", json!({"email": "test@windmill.dev"})).await?;
assert_eq!(resp.status(), 400);
// An off-origin destination is refused before anything is minted.
let resp = mint(
"SECRET_TOKEN",
json!({"email": "test2@windmill.dev", "rd": "https://evil.example/"}),
)
.await?;
assert_eq!(resp.status(), 400);
let resp = mint(
"SECRET_TOKEN",
json!({"email": "test2@windmill.dev", "rd": "/user/workspaces?x=1"}),
)
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
let path = link["url"]
.as_str()
.unwrap()
.split_once("/api")
.unwrap()
.1
.to_string();
let consume_url = format!("{base}{path}");
// A promotion inside the link's window is re-checked at open time: no session,
// and the link is not spent while the account is privileged.
sqlx::query("UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(
resp.headers()["location"],
"/user/login_link_expired?reason=invalid"
);
assert!(resp.headers().get("set-cookie").is_none());
sqlx::query("UPDATE password SET super_admin = false WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
// First open: session cookie for the target account, redirected to the stored rd.
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(resp.headers()["location"], "/user/workspaces?x=1");
assert_eq!(resp.headers()["referrer-policy"], "no-referrer");
let cookie = resp
.headers()
.get_all("set-cookie")
.iter()
.map(|c| c.to_str().unwrap().to_string())
.find(|c| c.starts_with("token="))
.expect("session cookie");
assert!(cookie.contains("HttpOnly"));
let session = cookie
.split(';')
.next()
.unwrap()
.trim_start_matches("token=")
.to_string();
let resp = client()
.get(format!("{base}/users/whoami"))
.header("Authorization", format!("Bearer {session}"))
.send()
.await?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?["email"],
"test2@windmill.dev"
);
// Second open: burned, no cookie, bounced to the explanation page.
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(
resp.headers()["location"],
"/user/login_link_expired?reason=used"
);
assert!(resp.headers().get("set-cookie").is_none());
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let mint = || {
client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "require_login_type": "pending_oauth"}))
.send()
};
// A password account is not the account the caller created: no link.
let resp = mint().await?;
assert_eq!(resp.status(), 409);
assert!(resp.text().await?.contains("login_type_mismatch"));
sqlx::query(
"UPDATE password SET login_type = 'pending_oauth', password_hash = NULL WHERE email = 'test2@windmill.dev'",
)
.execute(&db)
.await?;
let resp = mint().await?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn cloud_trial_offer_go_refuses_a_job_token(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
// The answer is a signed-in portal login for the account, so the job-token check
// must come before every other gate: a script holding `$WM_TOKEN` is refused outright,
// where a browser session reaches the next check (off cloud, "no offer").
let job_id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
VALUES ($1, 'test-workspace', 'test-user-2', 'u/test-user-2', 'script', 'deno', '{}'::jsonb)",
)
.bind(job_id)
.execute(&db)
.await?;
let job_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-2",
"job",
600,
"test2@windmill.dev",
&job_id,
None,
None,
)
.await?;
let go = |token: String| {
client()
.post(format!("{base}/users/cloud_trial_offer/go"))
.header("Authorization", format!("Bearer {token}"))
.send()
};
let resp = go(job_token).await?;
assert_eq!(resp.status(), 403);
assert!(resp.text().await?.contains("job token"));
let resp = go("SECRET_TOKEN_2".to_string()).await?;
assert_eq!(resp.status(), 404);
Ok(())
}
+564 -2
View File
@@ -46,7 +46,7 @@ use tracing::Instrument;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::per_minute_counter::PerMinuteCounter;
@@ -140,6 +140,16 @@ pub fn global_service() -> Router {
)
.route("/tokens/list", get(list_tokens))
.route("/tokens/impersonate", post(impersonate))
.route("/login_links", post(create_login_link))
.route(
"/cloud_trial_offer",
post(set_cloud_trial_offer).get(get_cloud_trial_offer),
)
.route("/cloud_trial_offer/go", post(go_cloud_trial_offer))
.route(
"/onboarding_profile",
post(set_onboarding_profile).get(get_onboarding_profile),
)
.route("/usage", get(get_usage))
.route("/all_runnables", get(get_all_runnables))
.route("/refresh_token", get(refresh_token))
@@ -158,6 +168,7 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route("/login_link/{token}", get(consume_login_link))
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -255,11 +266,14 @@ pub struct WorkspaceInvite {
#[derive(Deserialize)]
pub struct NewUser {
pub email: String,
pub password: String,
/// Required when `login_type` is `password` (the default), ignored otherwise.
pub password: Option<String>,
pub super_admin: bool,
pub name: Option<String>,
pub company: Option<String>,
pub skip_email: Option<bool>,
/// `password`, `pending_oauth`, or a configured OAuth login client key.
pub login_type: Option<String>,
}
#[derive(Deserialize)]
@@ -3181,6 +3195,503 @@ async fn impersonate(
Ok((StatusCode::CREATED, token))
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
#[derive(Deserialize)]
pub struct NewLoginLink {
pub email: String,
pub expires_in_s: Option<u32>,
pub rd: Option<String>,
/// Refuse to mint unless the account still has this login type: a caller re-entering an
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
}
#[derive(Serialize)]
pub struct LoginLink {
pub url: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
/// A post-login destination is only ever a same-origin path: anything else would hand the
/// fresh session's first navigation to another host. Control characters are refused because
/// browsers strip tab/newline from a `Location` before parsing it, so `/\t/host` reads as
/// the protocol-relative `//host`.
fn same_origin_rd(rd: Option<String>) -> Option<String> {
rd.filter(|r| {
r.starts_with('/')
&& !r.starts_with("//")
&& !r.contains('\\')
&& !r.chars().any(|c| c.is_ascii_control())
})
}
#[cfg(test)]
mod same_origin_rd_tests {
use super::same_origin_rd;
fn accepts(rd: &str) -> bool {
same_origin_rd(Some(rd.to_string())).is_some()
}
#[test]
fn only_plain_same_origin_paths_pass() {
assert!(accepts("/"));
assert!(accepts("/user/workspaces?rd=%2Fx"));
assert!(!accepts("https://evil.example/"));
assert!(!accepts("//evil.example/"));
assert!(!accepts("/\\evil.example/"));
assert!(!accepts("/\t/evil.example/"));
assert!(!accepts("/x\r\nSet-Cookie: a=b"));
assert!(!accepts("user/workspaces"));
}
}
/// Both provisioning writes reference `password(email)`; a typo'd address from the
/// provisioning script should read as "no such account", not as a foreign-key error.
async fn require_account(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, email: &str) -> Result<()> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)",
email
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::NotFound(format!("no account for {email}")));
}
Ok(())
}
fn login_link_redirect(location: String) -> Response {
(
StatusCode::FOUND,
[
("location", location),
("referrer-policy", "no-referrer".to_string()),
],
)
.into_response()
}
/// Mint a single-use link that signs `email` in when opened. The row is not a `token`:
/// it can only ever become a session, and burning it needs no cache invalidation.
async fn create_login_link(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(nl): Json<NewLoginLink>,
) -> Result<(StatusCode, Json<LoginLink>)> {
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let email = nl.email.to_lowercase();
let rd = match nl.rd {
Some(rd) => Some(same_origin_rd(Some(rd)).ok_or_else(|| {
Error::BadRequest("rd must be a same-origin path starting with /".to_string())
})?),
None => None,
};
let ttl = nl
.expires_in_s
.unwrap_or(LOGIN_LINK_DEFAULT_TTL_S)
.clamp(1, LOGIN_LINK_MAX_TTL_S);
let mut tx = db.begin().await?;
let target = sqlx::query!(
"SELECT super_admin, devops, login_type FROM password WHERE email = $1 AND disabled = false",
&email
)
.fetch_optional(&mut *tx)
.await?;
let Some(target) = target else {
return Err(Error::NotFound(format!("no active account for {email}")));
};
// A link is a full session for its account; whoever holds the minting credential
// must not be able to turn it into an instance-wide role.
if target.super_admin || target.devops {
return Err(Error::BadRequest(
"login links cannot target superadmin or devops accounts".to_string(),
));
}
if let Some(required) = nl.require_login_type.as_deref() {
if target.login_type != required {
return Err(Error::Generic(
StatusCode::CONFLICT,
format!(
"login_type_mismatch: {email} signs in with {}, not {required}",
target.login_type
),
));
}
}
let token = rd_string(32);
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl as i64);
sqlx::query!(
"INSERT INTO login_link (token_hash, email, rd, expiration, created_by)
VALUES ($1, $2, $3, $4, $5)",
hash_token(&token),
&email,
rd,
expires_at,
&authed.email,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.login_link.create",
ActionKind::Create,
"global",
Some(&email),
Some([("expires_in_s", &ttl.to_string()[..])].into()),
)
.await?;
tx.commit().await?;
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
#[derive(Deserialize)]
pub struct CloudTrialOfferUpdate {
pub email: String,
#[serde(default)]
pub consumed: bool,
}
#[derive(Deserialize)]
pub struct OnboardingProfileUpdate {
pub email: String,
pub profile: serde_json::Value,
}
#[derive(Serialize)]
pub struct OnboardingProfile {
pub profile: Option<serde_json::Value>,
}
/// Context the invite carried about this account's owner, written at provisioning.
/// Onboarding tailors itself from it (today: `touch_point` answers the source question
/// so it is never asked); everything degrades to the plain flow when absent.
async fn set_onboarding_profile(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(body): Json<OnboardingProfileUpdate>,
) -> Result<String> {
if !*CLOUD_HOSTED {
return Err(Error::NotFound("cloud only".to_string()));
}
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
if !body.profile.is_object() {
return Err(Error::BadRequest(
"profile must be a JSON object".to_string(),
));
}
let email = body.email.to_lowercase();
let mut tx = db.begin().await?;
require_account(&mut tx, &email).await?;
sqlx::query!(
"INSERT INTO cloud_onboarding_profile (email, profile, created_by) VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET profile = EXCLUDED.profile",
&email,
body.profile,
&authed.email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.onboarding_profile.set",
ActionKind::Update,
"global",
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("onboarding profile for {email} recorded"))
}
async fn get_onboarding_profile(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<OnboardingProfile> {
if !*CLOUD_HOSTED {
return Ok(Json(OnboardingProfile { profile: None }));
}
let profile = sqlx::query_scalar!(
"SELECT profile FROM cloud_onboarding_profile WHERE email = $1",
&authed.email
)
.fetch_optional(&db)
.await?;
Ok(Json(OnboardingProfile { profile }))
}
#[derive(Serialize)]
pub struct CloudTrialOffer {
pub offered: bool,
}
/// What the customer portal answers when asked to sign a cloud account in and start its
/// pre-approved trial.
pub enum PortalTrialLogin {
/// Send the browser here: a short-lived portal login that starts the trial on landing.
LoginUrl(String),
/// The portal will not start one (a subscription exists, or it knows no offer); the
/// offer is spent and the browser goes to the portal home instead.
Unavailable { reason: String, portal_url: String },
}
async fn set_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(body): Json<CloudTrialOfferUpdate>,
) -> Result<String> {
if !*CLOUD_HOSTED {
return Err(Error::NotFound("cloud only".to_string()));
}
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let email = body.email.to_lowercase();
let mut tx = db.begin().await?;
require_account(&mut tx, &email).await?;
if body.consumed {
sqlx::query!(
"UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
&email
)
.execute(&mut *tx)
.await?;
} else {
// A consumed offer stays consumed: a trial or subscription already exists for it.
sqlx::query!(
"INSERT INTO cloud_trial_offer (email, created_by) VALUES ($1, $2)
ON CONFLICT (email) DO NOTHING",
&email,
&authed.email
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"users.cloud_trial_offer.set",
ActionKind::Update,
"global",
Some(&email),
Some([("consumed", if body.consumed { "true" } else { "false" })].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"cloud trial offer for {email} {}",
if body.consumed {
"consumed"
} else {
"recorded"
}
))
}
async fn offered(db: &DB, email: &str) -> Result<bool> {
Ok(sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM cloud_trial_offer WHERE email = $1 AND consumed_at IS NULL)",
email
)
.fetch_one(db)
.await?
.unwrap_or(false))
}
async fn get_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<CloudTrialOffer> {
if !*CLOUD_HOSTED {
return Ok(Json(CloudTrialOffer { offered: false }));
}
Ok(Json(CloudTrialOffer {
offered: offered(&db, &authed.email).await?,
}))
}
/// Where the browser goes to start the pre-approved trial: a signed-in portal login, or
/// the portal's front page with the reason it could not start one.
#[derive(Serialize)]
pub struct CloudTrialOfferGo {
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// The one click that turns a cloud account's pre-approved offer into a trial: the portal
/// is asked for a login that starts it, and the browser is handed over. The portal is the
/// authority on whether the offer still stands; its refusal spends the offer here so the
/// sidebar stops advertising it.
async fn go_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<CloudTrialOfferGo> {
// The answer carries a signed-in portal login for this account: a credential for
// another system, and asking for it starts the trial. A script running as the offered
// user holds their identity through `$WM_TOKEN`, so a job token must not be able to
// fetch it and hand it to whoever wrote the script. It is a POST answered as JSON, not
// a redirecting GET, so a cross-site top-level navigation cannot start the trial with
// the SameSite=Lax session cookie either; the frontend navigates to `location` itself.
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN).".to_string(),
));
}
if !*CLOUD_HOSTED || !offered(&db, &authed.email).await? {
return Err(Error::NotFound(
"no pre-approved trial offer for this account".to_string(),
));
}
let outcome = crate::users_oss::portal_cloud_trial_login(&authed.email).await?;
let (location, reason) = match outcome {
PortalTrialLogin::LoginUrl(url) => (url, None),
PortalTrialLogin::Unavailable { reason, portal_url } => (portal_url, Some(reason)),
};
let mut tx = db.begin().await?;
if reason.is_some() {
sqlx::query!(
"UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
&authed.email
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"users.cloud_trial_offer.go",
ActionKind::Execute,
"global",
Some(&authed.email),
Some([("outcome", reason.as_deref().unwrap_or("login"))].into()),
)
.await?;
tx.commit().await?;
Ok(Json(CloudTrialOfferGo { location, reason }))
}
#[derive(Deserialize)]
struct LoginLinkQuery {
rd: Option<String>,
}
async fn consume_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(&token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
&headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
let mut tx = db.begin().await?;
let link = sqlx::query!(
"UPDATE login_link SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expiration > now()
RETURNING email, rd, created_by",
&t_hash
)
.fetch_optional(&mut *tx)
.await?;
let Some(link) = link else {
let used = sqlx::query_scalar!(
"SELECT consumed_at IS NOT NULL AS \"used!\" FROM login_link WHERE token_hash = $1",
&t_hash
)
.fetch_optional(&mut *tx)
.await?;
return bounce(match used {
Some(true) => "used",
Some(false) => "expired",
None => "invalid",
});
};
// Re-checked at open time and locked through session creation: a promotion inside
// the link's window must not turn a link minted for an ordinary account into a
// privileged session. The bounce drops the transaction, so the link is not spent.
let target = sqlx::query!(
"SELECT super_admin, devops FROM password WHERE email = $1 AND disabled = false FOR UPDATE",
&link.email
)
.fetch_optional(&mut *tx)
.await?;
let Some(target) = target else {
return bounce("invalid");
};
if target.super_admin || target.devops {
return bounce("invalid");
}
let session = create_session_token(&link.email, false, None, false, &mut tx, cookies).await?;
audit_log(
&mut *tx,
&AuditAuthor {
email: link.email.clone(),
username: link.email.clone(),
username_override: None,
token_prefix: Some(safe_token_prefix(&session)),
},
"users.login",
ActionKind::Create,
"global",
Some(&truncate_token(&session)),
Some(
[
("method", "login_link"),
("minted_by", link.created_by.as_str()),
]
.into(),
),
)
.await?;
tx.commit().await?;
let rd = link
.rd
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
}
#[derive(Deserialize)]
pub struct ImpersonateServiceAccountRequest {
pub username: String,
@@ -3571,12 +4082,63 @@ async fn get_all_runnables(
#[derive(Deserialize, Debug, Clone)]
pub struct LoginUserInfo {
pub email: Option<String>,
/// OIDC `email_verified` claim where the provider sends one.
#[serde(default, deserialize_with = "deserialize_lenient_bool")]
pub email_verified: Option<bool>,
pub name: Option<String>,
pub company: Option<String>,
pub preferred_username: Option<String>,
pub displayName: Option<String>,
}
/// Some providers (Cognito among them) send `email_verified` as the strings "true"/"false";
/// a strict bool would reject their whole userinfo document and break login.
fn deserialize_lenient_bool<'de, D: serde::Deserializer<'de>>(
d: D,
) -> std::result::Result<Option<bool>, D::Error> {
Ok(match Option::<serde_json::Value>::deserialize(d)? {
Some(serde_json::Value::Bool(b)) => Some(b),
Some(serde_json::Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
})
}
#[cfg(test)]
mod login_user_info_tests {
use super::LoginUserInfo;
fn email_verified(json: &str) -> Option<bool> {
serde_json::from_str::<LoginUserInfo>(json)
.unwrap()
.email_verified
}
#[test]
fn email_verified_accepts_bool_and_stringified_bool() {
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":true}"#),
Some(true)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"true"}"#),
Some(true)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"false"}"#),
Some(false)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"maybe"}"#),
None
);
assert_eq!(email_verified(r#"{"email":"a@b"}"#), None);
}
}
#[derive(Serialize)]
struct InstanceUsernameInfo {
username: String,
@@ -26,3 +26,13 @@ pub async fn impersonate_service_account(
"Service accounts require Windmill Enterprise Edition".to_string(),
))
}
#[cfg(not(feature = "private"))]
pub(crate) async fn portal_cloud_trial_login(
_email: &str,
) -> windmill_common::error::Result<crate::users::PortalTrialLogin> {
Err(windmill_common::error::Error::FeatureUnavailable(
"Starting a pre-approved trial from Windmill Cloud requires Windmill Enterprise Edition"
.to_string(),
))
}
+195 -2
View File
@@ -406,6 +406,28 @@ paths:
"400":
description: SMTP not configured
/auth/login_link/{token}:
get:
security: []
summary: consume a single-use login link, set the session cookie and redirect
operationId: consumeLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
- name: rd
in: query
required: false
schema:
type: string
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
/auth/reset_password:
post:
security: []
@@ -623,9 +645,14 @@ paths:
skip_email:
type: boolean
description: Skip sending email notifications to the user
login_type:
type: string
description: >-
password (default, requires `password`), pending_oauth (no credential
until the first OAuth login proving the address adopts the account), or
a configured OAuth login client key
required:
- email
- password
- super_admin
responses:
"201":
@@ -6384,6 +6411,172 @@ paths:
schema:
type: string
/users/login_links:
post:
summary: mint a single-use login link for an account (require superadmin)
operationId: createLoginLink
tags:
- user
requestBody:
description: target account and link options
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 900 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
require_login_type:
type: string
description: >-
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
responses:
"201":
description: login link minted
content:
application/json:
schema:
type: object
required:
- url
- expires_at
properties:
url:
type: string
expires_at:
type: string
format: date-time
"409":
description: the account does not have the required login type
/users/cloud_trial_offer:
post:
summary: record or consume a pre-approved self-hosted trial offer for a cloud account (require superadmin, cloud only)
operationId: setCloudTrialOffer
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
consumed:
type: boolean
description: mark the offer used (a trial or subscription now exists) instead of recording it
responses:
"200":
description: offer recorded or consumed
content:
text/plain:
schema:
type: string
get:
summary: whether the signed-in account holds an unconsumed pre-approved self-hosted trial offer (cloud only)
operationId: getCloudTrialOffer
tags:
- user
responses:
"200":
description: offer state
content:
application/json:
schema:
type: object
required:
- offered
properties:
offered:
type: boolean
/users/cloud_trial_offer/go:
post:
summary: start the signed-in account's pre-approved self-hosted trial on the customer portal (cloud only). A POST answered as JSON rather than a redirecting GET, so a cross-site navigation cannot start it; the browser navigates to `location` itself
operationId: goCloudTrialOffer
tags:
- user
responses:
"200":
description: where to go — the customer portal signed in with the trial being started, or the portal home with the reason the offer could not be used
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
reason:
type: string
description: present when the portal refused (e.g. the account already has a subscription); the offer is then spent
"403":
description: called with a job token
"404":
description: no offer for this account
/users/onboarding_profile:
post:
summary: record the invite context an account's onboarding tailors itself from (require superadmin, cloud only)
operationId: setOnboardingProfile
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- profile
properties:
email:
type: string
profile:
type: object
description: free-form context from the invite, every key optional. The frontend reads `touch_point` (answers onboarding's source question), `company` and `workspace_name` (prefill the first workspace's name), `hub_projects` (slugs surfaced first on an empty workspace), `tools` (integrations, used to pick hub projects when none are named) and `starter_prompts` (`[{label, prompt}]`, replacing the home page's example prompts); unknown keys are kept and ignored
responses:
"200":
description: profile recorded
content:
text/plain:
schema:
type: string
get:
summary: the invite context recorded for the signed-in account, if any (cloud only)
operationId: getOnboardingProfile
tags:
- user
responses:
"200":
description: the profile, or null when none was recorded
content:
application/json:
schema:
type: object
properties:
profile:
type: object
nullable: true
additionalProperties: true
/users/tokens/delete/{token_prefix}:
delete:
summary: delete token
@@ -33702,7 +33895,7 @@ components:
type: string
login_type:
type: string
enum: ["password", "github", "service_account"]
enum: ["password", "github", "service_account", "pending_oauth"]
super_admin:
type: boolean
devops:
+7
View File
@@ -21,6 +21,13 @@ pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
pub const COOKIE_NAME: &str = "token";
/// `password.login_type` of an account created for someone before they have signed in:
/// no credential of its own (password login and reset require `'password'`), reachable
/// only through a superadmin-minted login link until either `set_password` turns it into
/// a password account or the first OAuth login proving the same address adopts it and
/// rewrites `login_type` to the provider.
pub const PENDING_OAUTH_LOGIN_TYPE: &str = "pending_oauth";
/// Prefix for user-based permissioned_as values: "u/"
pub const PERMISSIONED_AS_USER_PREFIX: &str = "u/";
/// Prefix for group-based permissioned_as values: "g/"
+50
View File
@@ -0,0 +1,50 @@
# Auth surface: facts that are easy to get wrong
Symbols, not line numbers, are cited: they drift less.
- **Credential precedence** (`windmill-api-auth/src/auth.rs` `extract_token`): `Authorization: Bearer`
`token` cookie → `?token=` query param. A URL with `?token=` is a credential on every route, but
an existing cookie silently wins over it.
- **`AUTH_CACHE`** caches a token's identity for 120 s. Deleting a token row does not purge it: the
DB trigger (`migrations/20260316000001_token_hash_pk_swap.up.sql`) notifies only for
`label = 'session'` rows, and `delete_token` never calls `invalidate_token_from_cache`.
- **Sessions** are `token` rows with `label='session'` plus the HttpOnly `token` cookie, minted only
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
mints one for any non-job token but returns plain text, no redirect.
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
- **Every superadmin route refuses a job token**: `require_super_admin`
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes.
- **`login_type`** (`password` table) is a free-form `VARCHAR(50)`. Password login and password
reset require `login_type = 'password'`; `set_password` also accepts `pending_oauth` and turns
the account into a `password` one in the same statement (an account created ahead of its owner
gets its first credential that way, or through the OAuth claim below).
- **Login links** (`login_link` table, `POST /users/login_links` superadmin-only,
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤15 min, a session cookie and a
302 to a same-origin `rd`. `require_login_type` on the mint refuses (409) an account whose
`login_type` has moved on — the way a caller re-entering an account it created stops being
able to once the owner has a password or a provider.
- **Pre-approved trial offer** (`cloud_trial_offer`, cloud-only routes under
`/users/cloud_trial_offer`): written by a superadmin at provisioning, consumed by
`{consumed: true}` or by the portal's refusal; `…/go` is the one Windmill→portal hop that
mints a portal login, over the same `CUSTOMER_SERVICE_TOKEN` trust the onboarding hook uses
(`users_ee.rs`, the portal's admin token). It never expires on its own.
- **OAuth login** (`oauth2_ee.rs` `login_externally`, decision in `existing_login_decision`)
matches an existing account by lowercased email only. Same provider → login; a
`pending_oauth` account (see `PENDING_OAUTH_LOGIN_TYPE`) is **claimed** by the first login
whose address the provider itself asserted and did not mark unverified — `login_type` becomes
the client key and the hash is nulled; otherwise `require_preexisting_user_for_oauth` decides:
on, *every* existing account is loggable-into by any provider; off, "exists but with a
different login type". A new account gets `login_type = <client key>`.
- **OAuth email trust**: `LoginUserInfo.email_verified` is read leniently (bool or
"true"/"false" strings) and is only consulted for the claim above; only GitHub is filtered to
`primary && verified`; a missing email is fabricated from `name` as `<name>@windmill.dev` and
reaches `login_externally` with `email_asserted = false`.
- **`GET /api/oauth/login/{client}`** is an unauthenticated 302 to the provider — a plain link
from any page starts SSO.
- **`CLOUD_HOSTED`** is presence-tested (`windmill-common/src/worker.rs`): `CLOUD_HOSTED=false`
still enables cloud mode. Of the routes above only the cloud trial offer and onboarding
profile routes are cloud-gated; for the rest, cloud only adds quotas.
- **`CREATE_WORKSPACE_REQUIRE_SUPERADMIN`** defaults to `true` when unset; only the literal
`"true"` enables it when set.
+2 -2
View File
@@ -4,10 +4,10 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 49 registered actions across eighteen features (`ai_session`, `ai_chat`,
It currently carries 50 registered actions across nineteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
`usage_meter`, `sso_groups_claim`). Nearly all of the
`usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+9
View File
@@ -6,6 +6,15 @@ export function isCloudHosted(): boolean {
// may be missing or a stub with no `location`. Same defensive shape as
// `isChromiumBrowser`.
if (!BROWSER) return false
// Dev only: the cloud-specific UI (quotas, plan upgrade, the pre-approved trial offer)
// is otherwise unreachable from localhost. `localStorage.cloudHostedOverride = '1'` opts
// a browser in against a backend started with CLOUD_HOSTED.
if (
import.meta.env.DEV &&
globalThis.window?.localStorage?.getItem('cloudHostedOverride') === '1'
) {
return true
}
return globalThis.window?.location?.hostname == 'app.windmill.dev'
}
@@ -1086,8 +1086,8 @@
the flow editor, which skin approval steps are given, how data tables and their
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, and the name of any public hub project imported from the home page and how far
that import got, last 30 days)</li
point, the name of any public hub project imported from the home page and how far
that import got, and whether a pre-approved trial offer was opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1150,8 +1150,8 @@
the flow editor, which skin approval steps are given, how data tables and their
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, and the name of any public hub project imported from the home page and how far
that import got, last 30 days)</li
point, the name of any public hub project imported from the home page and how far
that import got, and whether a pre-approved trial offer was opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
+7
View File
@@ -1,4 +1,5 @@
<script module lang="ts">
import { noteSessionEmail } from '$lib/onboardingProfile'
import type { LastLoginMethod } from '$lib/lastLoginMethod'
/** Feeds the login card a fixed instance configuration instead of the live one.
@@ -264,6 +265,9 @@
try {
await UserService.login({ requestBody })
// The session changed under a page that stays mounted: what was cached for the
// previous account must not be served to this one.
noteSessionEmail(email)
} catch (err) {
failLogin(loginErrorMessage(err), 'both', requestBody)
return
@@ -517,6 +521,9 @@
function finishOauthFlow(via: 'postMessage' | 'storage' | 'poll', win?: Window) {
if (oauthFlowDone) return
oauthFlowDone = true
// The popup replaced the session under this still-mounted page; whatever the
// previous account had cached must not be served to the new one.
noteSessionEmail(undefined)
confirmPendingLoginMethod()
console.log(`oauth: signaled via ${via}`)
if (win && !win.closed) win.close()
@@ -46,10 +46,21 @@
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import { USER_SETTINGS_HASH } from '../sidebar/settings'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { onboardingProfile } from '$lib/onboardingProfile'
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
let value = $state('')
// The stock examples, unless the invite that brought this person here wrote prompts for
// them — those replace the set outright rather than joining it, since a prompt written
// for someone's own stack next to "Ban Discord users" reads as the generic one.
let examples = $state(homeAIExamples)
void onboardingProfile().then((p) => {
if (!p?.starter_prompts?.length) return
examples = p.starter_prompts
promptIndex = 0
placeholder = examples[0].prompt
})
let placeholder = $state(homeAIExamples[0].prompt)
let placeholderVisible = $state(true)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
@@ -130,7 +141,7 @@
}
}
const prompts = homeAIExamples.map((e) => e.prompt)
let prompts = $derived(examples.map((e) => e.prompt))
const CYCLE_MS = 7_000
// Must match the `duration-*` class on the placeholder overlay: the swap happens once the
@@ -273,7 +284,7 @@
<div class="flex items-center justify-between gap-2 pt-2">
{#if showComposer && !collapsed}
<div class="flex flex-row flex-wrap items-center gap-1.5">
{#each homeAIExamples as example (example.label)}
{#each examples as example (example.label)}
<Button
variant="default"
unifiedSize="xs"
@@ -6,8 +6,10 @@
hubAppIcon,
hubBrowserUrl,
hubProjectCatalogue,
pinHubProjects,
type HubProjectPick
} from '$lib/hubProject'
import { onboardingProfile } from '$lib/onboardingProfile'
import { workspaceStore } from '$lib/stores'
interface Props {
@@ -40,9 +42,19 @@
untrack(() => {
list?.setLoader(async (page: number, perPage: number) => {
try {
const all = await hubProjectCatalogue(workspace)
const [all, profile] = await Promise.all([
hubProjectCatalogue(workspace),
onboardingProfile()
])
loadFailed = false
return all.slice((page - 1) * perPage, page * perPage)
const rows = pinHubProjects(all, profile)
const pinned = rows.filter((r) => r.pinned).length
// The snippet gets the row alone, not its index, so the row that opens the
// plain catalogue after the picks is flagged here.
return rows.slice((page - 1) * perPage, page * perPage).map((r, i) => ({
...r,
leadsCatalogue: pinned > 0 && (page - 1) * perPage + i === pinned
}))
} catch (error) {
loadFailed = true
throw error
@@ -88,10 +100,21 @@
<!-- The height comes from the flex chain above: DataTable's own container is `h-full`, so
the scroll box inside it is bounded, which is what lets the list page. -->
<InfiniteList bind:this={list} noBorder rounded={false}>
{#snippet customRow({ item }: { item: HubProjectPick })}
{#snippet customRow({
item
}: {
item: HubProjectPick & { pinned: boolean; leadsCatalogue: boolean }
})}
{@const Icon = hubAppIcon(item.iconApps[0] ?? '')}
<tr>
<td class="p-0">
{#if item.leadsCatalogue}
<p
class="border-b border-border-light bg-surface-secondary px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-tertiary"
>
More from the hub
</p>
{/if}
<button
class="group flex w-full items-start gap-3 border-b border-border-light px-3 py-2.5 text-left hover:bg-surface-hover"
onclick={() => onPick(item)}
@@ -109,6 +132,13 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-semibold text-emphasis">{item.name}</span>
{#if item.pinned}
<span
class="shrink-0 rounded bg-surface-accent-selected px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent"
>
Picked for you
</span>
{/if}
{#if item.stars > 0}
<span
class="flex shrink-0 items-center gap-0.5 text-2xs font-normal text-tertiary"
@@ -1,4 +1,5 @@
<script lang="ts">
import { accountSetup } from '$lib/components/sidebar/accountSetup.svelte'
import { usersWorkspaceStore } from '$lib/stores'
import { UserService } from '$lib/gen'
import { Button } from '$lib/components/common'
@@ -8,6 +9,9 @@
let newPassword = $state<string | undefined>(undefined)
let passwordError = $state<string | undefined>(undefined)
let login_type = $state<string>('none')
// A credential-less account may only take the password road where password login is
// on; the backend refuses otherwise, this keeps the form from offering a dead end.
let passwordAllowed = $state(true)
$effect(() => {
loadLoginType()
@@ -15,6 +19,9 @@
async function loadLoginType(): Promise<void> {
login_type = (await UserService.globalWhoami()).login_type
if (login_type == 'pending_oauth') {
passwordAllowed = !(await UserService.isPasswordLoginDisabled().catch(() => false))
}
}
async function setPassword(): Promise<void> {
@@ -25,6 +32,10 @@
}
})
sendUserToast('Your password was successfully updated')
await loadLoginType()
// A credential-less account finishing here, rather than through the sidebar's
// prompt, must clear that prompt too.
await accountSetup.refresh()
} else {
sendUserToast('Specify a new password value to change your password', true)
}
@@ -44,7 +55,7 @@
<label class="flex flex-col gap-1 w-120">
<span class="text-xs text-emphasis font-semibold">Password</span>
{#if login_type == 'password'}
{#if login_type == 'password' || (login_type == 'pending_oauth' && passwordAllowed)}
<div class="flex flex-row gap-1 items-center">
<TextInput
inputProps={{ autocomplete: 'new-password', type: 'password' }}
@@ -0,0 +1,48 @@
<script lang="ts">
import { KeyRound } from 'lucide-svelte'
import MenuButton from './MenuButton.svelte'
import { accountSetup } from './accountSetup.svelte'
/**
* Shown while the account has no credentials of its own: one that came in through a
* single-use sign-in link has no way back once this session ends, so it stays until
* that is fixed and has no dismiss. The host decides whether it is shown; the row opens
* the same modal the Settings entry does.
*
* A menu row like the ones under it, tinted rather than filled: the accent selection
* surface with accent text says "this one, act on it" in the rail's own vocabulary,
* where a filled block would outshout every real action on the page.
*/
interface Props {
isCollapsed: boolean
}
let { isCollapsed }: Props = $props()
// Same words as the Settings entry; both lines fit the narrowest expanded rail.
const label = 'Finish account setup'
const reason = 'No sign-in method yet'
</script>
<div class="relative">
<MenuButton
{label}
sublabel={isCollapsed ? undefined : reason}
icon={KeyRound}
iconClasses="text-accent"
labelClass="text-accent font-semibold"
buttonClass="border border-border-selected/50 bg-surface-accent-selected hover:bg-surface-accent-selected hover:border-border-selected"
{isCollapsed}
stopPropagationOnClick
on:click={() => (accountSetup.open = true)}
/>
{#if isCollapsed}
<!-- No words in the collapsed rail, so the pulse carries "waiting on you", placed
where the rail puts its other badges. -->
<span class="pointer-events-none absolute right-1 top-1 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-surface-accent-primary opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-surface-accent-primary"></span>
</span>
{/if}
</div>
@@ -0,0 +1,173 @@
<script lang="ts">
import type { Component } from 'svelte'
import { base } from '$lib/base'
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { Button } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { GithubIcon, GitlabIcon, GoogleIcon } from '$lib/components/icons'
import { OauthService } from '$lib/gen'
// An account created through a pre-approved invite has no credentials yet
// (`login_type` = pending_oauth): it is entered through single-use links. Either a
// password or the first provider sign-in with the same address makes it a normal
// account; the entry disappears with `login_type`, so nothing is stored here.
let {
open = $bindable(),
email,
onDone
}: { open: boolean; email: string; onDone: () => void } = $props()
const icons: Record<string, Component<any>> = {
google: GoogleIcon,
github: GithubIcon,
gitlab: GitlabIcon
}
let logins = $state<{ type: string; displayName: string }[]>([])
// The instance's SAML entry point, when SSO is configured: a SAML assertion adopts a
// credential-less account the way an OAuth login does.
let saml = $state<string | undefined>(undefined)
// Where password login is off, a password would be a credential that cannot sign in;
// the backend refuses it too, this just keeps the dead end off the screen.
let passwordAllowed = $state(true)
let password = $state('')
let saving = $state(false)
const labels: Record<string, string> = { github: 'GitHub', google: 'Google', gitlab: 'GitLab' }
$effect(() => {
if (!open) return
UserService.isPasswordLoginDisabled()
.then((disabled) => (passwordAllowed = !disabled))
.catch(() => (passwordAllowed = true))
OauthService.listOauthLogins()
.then((r) => {
logins = (r.oauth ?? []).map((l) => ({
type: l.type,
displayName:
l.display_name || labels[l.type] || l.type.charAt(0).toUpperCase() + l.type.slice(1)
}))
saml = r.saml || undefined
})
.catch((err) => {
console.warn('Could not list OAuth logins', err)
logins = []
saml = undefined
})
})
async function setPassword() {
if (password.length < 8) {
sendUserToast('Use at least 8 characters', true)
return
}
saving = true
try {
await UserService.setPassword({ requestBody: { password } })
sendUserToast('Password set. You can sign in with it from now on.')
open = false
onDone()
} catch (err) {
sendUserToast(
'Could not set the password: ' + (err instanceof Error ? err.message : err),
true
)
} finally {
saving = false
}
}
</script>
<Modal title="Finish setting up your account" bind:open cancelText="Later">
<div class="flex flex-col gap-5">
<p class="text-sm text-secondary">
Your account <span class="font-medium text-primary">{email}</span> was created from an invite and
has no sign-in method of its own yet. Pick one so you can come back any time.
</p>
{#if logins.length === 0 && !saml && !passwordAllowed}
<p class="text-sm text-secondary">
No sign-in method is available on this instance right now; ask an administrator.
</p>
{/if}
{#if logins.length > 0 || saml}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis">Sign in with a provider</span>
<div class="grid gap-2">
{#each logins as login (login.type)}
{@const Icon = icons[login.type]}
<Button
variant="default"
unifiedSize="lg"
startIcon={Icon ? { icon: Icon, classes: 'h-4' } : undefined}
onClick={() => {
// Read by the OAuth callback: a provider asserting another address is refused
// rather than opening a second account under it (see oauth2_ee.rs).
document.cookie = `finish_setup=${encodeURIComponent(email)}; path=/; max-age=600; SameSite=Lax`
window.location.assign(`${base}/api/oauth/login/${login.type}`)
}}
>
Continue with {login.displayName}
</Button>
{/each}
{#if saml}
<Button
variant="default"
unifiedSize="lg"
onClick={() => {
// The IdP posts to the ACS cross-site, where a SameSite cookie is not
// sent, so the address rides in the RelayState the ACS lifts out. The
// cookie stays as well: an IdP that drops RelayState but shares the
// instance's site still delivers it, and either channel refuses.
document.cookie = `finish_setup=${encodeURIComponent(email)}; path=/; max-age=600; SameSite=Lax`
try {
const url = new URL(saml!)
url.searchParams.set('RelayState', `/?finish_setup=${encodeURIComponent(email)}`)
window.location.assign(url.toString())
} catch (e) {
console.error('Could not set SAML RelayState', e)
sendUserToast('Could not start sign-in, please try again.', true)
}
}}
>
Continue with SSO
</Button>
{/if}
</div>
<p class="text-2xs text-secondary">
Sign in to the provider as {email}; a different address is refused and you stay signed in
here.
</p>
</div>
{#if passwordAllowed}
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-border-light"></div>
<span class="text-2xs uppercase text-secondary">or</span>
<div class="h-px flex-1 bg-border-light"></div>
</div>
{/if}
{/if}
{#if passwordAllowed}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis">Set a password</span>
<div class="flex flex-row gap-2 items-center">
<TextInput
inputProps={{
autocomplete: 'new-password',
type: 'password',
placeholder: 'At least 8 characters'
}}
bind:value={password}
/>
<Button variant="accent" unifiedSize="md" disabled={saving} onClick={setPassword}>
Set password
</Button>
</div>
<p class="text-2xs text-secondary"
>A password account keeps signing in with the password only.</p
>
</div>
{/if}
</div>
</Modal>
@@ -51,6 +51,9 @@
// Accessible name when the visible label is absent or only shown some of
// the time, so the button stays announceable in every state.
ariaLabel?: string | undefined
// Classes for the label line only — `class` reaches the button, the label and the
// sublabel alike, which is the wrong tool for colouring one line of the two.
labelClass?: string | undefined
}
let {
@@ -75,7 +78,8 @@
showChevron = false,
emphasizeLabel = false,
disableTitle = false,
ariaLabel = undefined
ariaLabel = undefined,
labelClass = undefined
}: Props = $props()
let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
@@ -161,7 +165,8 @@
'whitespace-pre truncate w-full',
emphasizeLabel ? 'text-primary text-sm font-semibold' : sidebarClasses.text,
'transition-all',
classNames
classNames,
labelClass
)}
title={disableTitle ? undefined : label}
>
@@ -17,7 +17,8 @@
Newspaper,
Crown,
Gauge,
Trash2
Trash2,
KeyRound
} from 'lucide-svelte'
import { base } from '$app/paths'
import { goto } from '$lib/navigation'
@@ -35,6 +36,7 @@
import SideBarNotification from './SideBarNotification.svelte'
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
import { accountSetup } from './accountSetup.svelte'
import { EXECUTIONS_HINT } from './executionsHint'
import {
userWorkspaces,
@@ -207,6 +209,10 @@
: [])
])
// An account entered through an invite link that still has no credentials of its own;
// the entry (and the sidebar banner it echoes) disappears once it does.
let pendingSetup = $derived(accountSetup.pending)
const items = $derived<Item[]>([
{
displayName: 'Help',
@@ -226,6 +232,17 @@
: ($userStore?.email ?? 'User'),
icon: $userStore?.is_admin || $userStore?.non_member ? Crown : User,
submenuItems: [
...(pendingSetup
? [
{
displayName: 'Finish account setup',
icon: KeyRound,
// The dropdown closes on this click; the modal opens once it is gone so its own
// buttons don't compete with the menu's outside-click handling.
action: () => setTimeout(() => (accountSetup.open = true), 50)
}
]
: []),
{
displayName: 'Account settings',
icon: Settings,
@@ -1,8 +1,18 @@
<script module lang="ts">
// A refusal spends the offer for good, and the layout mounts one instance of this
// component per breakpoint: kept at module level so crossing it does not lose the
// explanation and the way to the portal, which the next instance can never refetch.
// The module outlives a same-tab sign-out, so the record names the account it answers
// and is shown to that account only.
let refusal = $state<{ email: string; reason: string; location: string } | null>(null)
</script>
<script lang="ts">
import { resource } from 'runed'
import { goto } from '$lib/navigation'
import { isCloudHosted } from '$lib/cloud'
import { WorkspaceService } from '$lib/gen'
import { AlertTriangle, Sparkles } from 'lucide-svelte'
import { UserService, WorkspaceService } from '$lib/gen'
import {
isPremiumStore,
usageStore,
@@ -13,6 +23,7 @@
} from '$lib/stores'
import { refreshExecutions } from '$lib/usage.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { sendUserToast } from '$lib/toast'
import { scopedValue, tagged } from '$lib/utils/scopedValue'
import { Button } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
@@ -27,6 +38,43 @@
let open = $state(false)
// A pre-approved self-hosted Enterprise trial travels with accounts created through an
// invite; the offer lives on the account, not the workspace, and never expires. The
// click hands the browser to the customer portal already signed in, which starts it.
let trialOffered = $state(false)
$effect(() => {
if (!isCloudHosted()) return
UserService.getCloudTrialOffer()
.then((r) => (trialOffered = r.offered))
.catch(() => (trialOffered = false))
})
let starting = $state(false)
let trialRefusal = $derived(
refusal && $userStore?.email && refusal.email === $userStore.email ? refusal : null
)
async function startPreApprovedTrial() {
if (starting) return
starting = true
try {
// A POST, so nothing but this click can start the trial; the answer is where to go.
const { location, reason } = await UserService.goCloudTrialOffer()
logFeatureUsage('cloud_trial_offer', 'go')
if (reason) {
// Recorded where the button was, so it stays readable for the rest of the session
// without a toast running its timer, and the person chooses whether to go.
trialOffered = false
refusal = { email: $userStore?.email ?? '', reason, location }
starting = false
return
}
window.location.assign(location)
} catch (e) {
console.error('Could not start the pre-approved trial:', e)
sendUserToast('Could not start the trial right now, please try again', true)
starting = false
}
}
// Seat count for a paid workspace, the basis of its included executions. The server
// resolves a fork to the workspace its plan is billed on and counts the seats there,
// because neither is answerable from here: a fork's member list is a subset of that
@@ -178,6 +226,77 @@
</svg>
{/snippet}
{#if isCloudHosted() && trialOffered}
<div class="px-2 pt-2">
<Tooltip placement="right" class="w-full">
{#snippet text()}
Your 30-day self-hosted Enterprise trial is pre-approved — start it whenever you're ready.
{/snippet}
{#if isCollapsed}
<Button
variant="accent-secondary"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Sparkles }}
onclick={startPreApprovedTrial}
disabled={starting}
aria-label="Start your pre-approved Enterprise trial"
/>
{:else}
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{ icon: Sparkles }}
onclick={startPreApprovedTrial}
disabled={starting}
>
Start your Enterprise trial
</Button>
{/if}
</Tooltip>
</div>
{/if}
<!-- The refusal replaces the button it answers, in the button's own shape: a
collapsed rail gets the icon with the explanation in its tooltip, an expanded one
the sentence and the way to the portal. The status region is always present and empty
until then, since assistive technology announces what arrives in a live region but not
a region that appears already filled; the spacing sits inside so an empty one is 0px. -->
<div role="status">
{#if trialRefusal}
<div class="px-2 pt-2">
{#if isCollapsed}
<Tooltip placement="right">
{#snippet text()}
The trial could not be started: {trialRefusal?.reason}. The customer portal has the
details.
{/snippet}
<Button
variant="default"
unifiedSize="sm"
iconOnly
startIcon={{ icon: AlertTriangle }}
onclick={() => window.location.assign(trialRefusal!.location)}
aria-label="The trial could not be started; open the customer portal"
/>
</Tooltip>
{:else}
<p class="mb-1.5 text-2xs text-secondary">
The trial could not be started: {trialRefusal.reason}.
</p>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: AlertTriangle }}
onclick={() => window.location.assign(trialRefusal!.location)}
>
Open the customer portal
</Button>
{/if}
</div>
{/if}
</div>
{#if isCloudHosted() && tightest}
<div class="px-2 pt-2 pb-2">
<Tooltip placement="right" class="w-full">
@@ -11,7 +11,8 @@
import { USER_SETTINGS_HASH } from './settings'
import { isCloudHosted } from '$lib/cloud'
import { twMerge } from 'tailwind-merge'
import { Crown, ServerCog, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte'
import { Crown, KeyRound, ServerCog, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte'
import { accountSetup } from './accountSetup.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import MenuButton from './MenuButton.svelte'
import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents'
@@ -28,6 +29,9 @@
let { isCollapsed = false, lightMode = false, createMenu }: Props = $props()
// An account entered through an invite link that still has no credentials of its own.
let pendingSetup = $derived(accountSetup.pending)
const itemClass = twMerge(
'text-secondary text-left font-normal text-xs ',
'flex flex-row items-center gap-2 px-4 py-3 w-full',
@@ -72,6 +76,16 @@
{/if}
</div>
<div class="py-1">
{#if pendingSetup}
<MenuItem
onClick={() => setTimeout(() => (accountSetup.open = true), 50)}
class={itemClass}
{item}
>
<KeyRound size={16} />
Finish account setup
</MenuItem>
{/if}
<MenuItem href={USER_SETTINGS_HASH} class={itemClass} {item}>
<Settings size={16} />
Account settings
@@ -0,0 +1,63 @@
import { UserService } from '$lib/gen'
import { noteSessionEmail } from '$lib/onboardingProfile'
// An account that entered through an invite's sign-in link has no credentials of its own
// (`login_type` pending_oauth) until it sets a password or an OAuth login adopts it. Every
// surface that nags about it — the sidebar banner, the Settings entry, the mobile menu —
// reads this one flag and opens the one modal, so finishing on any of them clears all.
let pending = $state(false)
let open = $state(false)
let inflight: Promise<void> | undefined
// Bumped on sign-out so a lookup still in flight for the previous account cannot land
// its answer on the next one.
let generation = 0
export const accountSetup = {
/** Whether the signed-in account still has to set a password or connect a sign-in. */
get pending() {
return pending
},
/** The finish-setup modal, hosted once by the logged-in layout. */
get open() {
return open
},
set open(v: boolean) {
open = v
},
/** Forget the signed-out account; the module outlives a same-tab sign-out. */
reset() {
generation++
inflight = undefined
pending = false
open = false
},
/**
* Re-read the login type. Shared across callers mounting at the same time so the
* sidebar's several readers cost one request, not one each.
*/
refresh(): Promise<void> {
if (!inflight) {
const started = generation
inflight = UserService.globalWhoami()
.then((me) => {
if (started !== generation) return
noteSessionEmail(me.email)
pending = me.login_type === 'pending_oauth'
// The finish-setup marker is set for one provider round trip; a SAML one
// never comes back through the page that clears it, so it is dropped as
// soon as the account is known to be adopted, not left to refuse a later
// sign-in as a mismatch.
if (!pending) {
document.cookie = 'finish_setup=; path=/; max-age=0; SameSite=Lax'
}
})
.catch(() => {
if (started === generation) pending = false
})
.finally(() => {
if (started === generation) inflight = undefined
})
}
return inflight
}
}
@@ -5,6 +5,7 @@
import TextInput from '$lib/components/text_input/TextInput.svelte'
import CreateWorkspaceInner from './CreateWorkspaceInner.svelte'
import { UserService, WorkspaceService } from '$lib/gen'
import { onboardingProfile } from '$lib/onboardingProfile'
import { usersWorkspaceStore } from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { sendUserToast } from '$lib/toast'
@@ -65,15 +66,24 @@
// Settled apart: the policy decides whether this form may submit at all, the suggested
// name is cosmetic, and neither failure should decide the other.
policyFailed = false
const [me, policy] = await Promise.allSettled([
const [me, policy, profile] = await Promise.allSettled([
UserService.globalWhoami(),
loadUsernamePolicy()
loadUsernamePolicy(),
onboardingProfile()
])
// An invited account may arrive knowing what its workspace is called — the company the
// invite named, or a name chosen for it — and that beats the one derived from the
// account. Read here rather than passed in, so every host of this form agrees.
const invited =
profile.status === 'fulfilled'
? (profile.value?.workspace_name ?? profile.value?.company)
: undefined
if (!nameEdited) {
name =
me.status === 'fulfilled'
invited ||
(me.status === 'fulfilled'
? defaultWorkspaceName(me.value.name, me.value.email)
: 'My workspace'
: 'My workspace')
}
if (policy.status === 'rejected') {
console.error('Could not read the username policy:', policy.reason)
+27 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('./gen', () => ({ HubPublishService: {}, SettingService: {} }))
vi.mock('./components/icons', () => ({ appIconComponent: () => undefined }))
import { hubProjectDescription } from './hubProject'
import { hubProjectDescription, pinHubProjects } from './hubProject'
describe('hubProjectDescription', () => {
it('prefers the description field when the hub has one', () => {
@@ -49,3 +49,29 @@ describe('hubProjectDescription', () => {
expect(hubProjectDescription({})).toBe('')
})
})
describe('pinHubProjects', () => {
const pick = (slug: string, apps: string[], stars: number) =>
({ id: slug, slug, name: slug, summary: '', description: '', author: '', apps, iconApps: apps, stars }) as any
const all = [pick('a', ['slack'], 9), pick('b', ['hubspot'], 5), pick('c', ['hubspot', 'slack'], 2)]
it('hoists the named slugs in the invite order and skips ones the hub no longer lists', () => {
expect(pinHubProjects(all, { hub_projects: ['c', 'gone', 'a'] }).map((p) => [p.slug, p.pinned])).toEqual([
['c', true],
['a', true],
['b', false]
])
})
it('falls back to the best projects built on a known tool when no slugs are named', () => {
expect(pinHubProjects(all, { tools: ['hubspot'] }).map((p) => [p.slug, p.pinned])).toEqual([
['b', true],
['c', true],
['a', false]
])
})
it('leaves the catalogue as is, all unpinned, without a profile', () => {
expect(pinHubProjects(all, null).every((p) => !p.pinned)).toBe(true)
})
})
+36
View File
@@ -205,3 +205,39 @@ async function loadCatalogue(workspace: string): Promise<HubProjectPick[]> {
}))
.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))
}
/** How many tool-matched projects to hoist when the invite named tools but no projects. */
const TOOL_PICKS_MAX = 3
/**
* The catalogue with the projects an invite picked for this person moved to the front, in
* the invite's order, each flagged so the row can say why it is there. Named slugs win; when
* none are named but the person's tools are known, the most-starred projects built on one
* of those tools stand in. Anything the hub no longer lists is skipped, so a stale invite
* costs nothing. `pinned` is set on every row rather than left off the rest: the list
* renders from it, and a missing flag would read as "not pinned" only by accident.
*/
export function pinHubProjects(
all: HubProjectPick[],
picks: { hub_projects?: string[]; tools?: string[] } | null | undefined
): (HubProjectPick & { pinned: boolean })[] {
let pinnedSlugs: string[] = []
if (picks?.hub_projects?.length) {
const known = new Set(all.map((p) => p.slug))
pinnedSlugs = picks.hub_projects.filter((s) => known.has(s))
} else if (picks?.tools?.length) {
const tools = new Set(picks.tools)
pinnedSlugs = all
.filter((p) => p.apps.some((a) => tools.has(a.toLowerCase())))
.slice(0, TOOL_PICKS_MAX)
.map((p) => p.slug)
}
if (!pinnedSlugs.length) return all.map((p) => ({ ...p, pinned: false }))
const bySlug = new Map(all.map((p) => [p.slug, p]))
const pinned = pinnedSlugs.map((s) => ({ ...bySlug.get(s)!, pinned: true }))
const pinnedSet = new Set(pinnedSlugs)
return [
...pinned,
...all.filter((p) => !pinnedSet.has(p.slug)).map((p) => ({ ...p, pinned: false }))
]
}
+4
View File
@@ -1,3 +1,5 @@
import { noteSessionEmail } from './onboardingProfile'
import { accountSetup } from './components/sidebar/accountSetup.svelte'
import { UserService } from '$lib/gen'
import { clearStores } from './storeUtils'
@@ -6,6 +8,8 @@ import { clearStores } from './storeUtils'
export async function clearUser() {
try {
noteSessionEmail(undefined)
accountSetup.reset()
clearStores()
await UserService.logout()
} catch (error) {}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
const { getOnboardingProfile } = vi.hoisted(() => ({ getOnboardingProfile: vi.fn() }))
vi.mock('./gen', () => ({ UserService: { getOnboardingProfile } }))
import { noteSessionEmail, onboardingProfile, parseOnboardingProfile } from './onboardingProfile'
describe('onboardingProfile', () => {
it('serves each session its own profile, never the previous one', async () => {
getOnboardingProfile
.mockResolvedValueOnce({ profile: { workspace_name: 'Acme' } })
.mockResolvedValueOnce({ profile: { workspace_name: 'Globex' } })
noteSessionEmail('a@example.com')
expect((await onboardingProfile())?.workspace_name).toBe('Acme')
expect((await onboardingProfile())?.workspace_name).toBe('Acme')
expect(getOnboardingProfile).toHaveBeenCalledTimes(1)
noteSessionEmail('b@example.com')
expect((await onboardingProfile())?.workspace_name).toBe('Globex')
expect(getOnboardingProfile).toHaveBeenCalledTimes(2)
})
})
describe('parseOnboardingProfile', () => {
it('keeps the well-formed keys when another one is malformed', () => {
expect(
parseOnboardingProfile({
touch_point: 'outbound:q3',
workspace_name: ' Acme ',
hub_projects: ['uptime-monitor', 42, '', 'uptime-monitor'],
starter_prompts: [
{ label: 'Sync', prompt: 'Sync HubSpot to Postgres' },
{ label: 'no prompt' },
{ label: 'Sync', prompt: 'a second prompt under the same label' }
],
tools: ['HubSpot']
})
).toEqual({
touch_point: 'outbound:q3',
company: undefined,
workspace_name: 'Acme',
hub_projects: ['uptime-monitor'],
starter_prompts: [{ label: 'Sync', prompt: 'Sync HubSpot to Postgres' }],
tools: ['hubspot']
})
})
it('is null for an empty, non-object, or entirely unusable profile', () => {
expect(parseOnboardingProfile(null)).toBeNull()
expect(parseOnboardingProfile([])).toBeNull()
expect(parseOnboardingProfile({ starter_prompts: 'not a list', hub_projects: [] })).toBeNull()
})
})
+116
View File
@@ -0,0 +1,116 @@
import { UserService } from './gen'
import { WORKSPACE_NAME_MAX_LENGTH } from './utils/workspaceId'
/**
* What an invited cloud account arrives knowing about itself: the context the invite
* carried, recorded by the provisioning script before the person ever signs in. Every key
* is optional and the whole thing is absent for a self-signup, so each consumer must read
* it as a hint and fall back to its default.
*
* The blob is written by an outbound pipeline that may grow new keys at any time; this is
* the only place that decides which of them mean anything to the frontend, and how far a
* value is trusted before it is shown.
*/
export interface OnboardingProfile {
/** Answers onboarding's source question; `outbound:<campaign>`. */
touch_point?: string
company?: string
/** What to call the first workspace; `company` is the fallback. */
workspace_name?: string
/** Hub project slugs to surface first on an empty workspace. */
hub_projects?: string[]
/** Replace the home page's example prompts with ones written for this person. */
starter_prompts?: StarterPrompt[]
/** Integrations they are known to use, for picking hub projects when none are named. */
tools?: string[]
}
export interface StarterPrompt {
label: string
prompt: string
}
const MAX_LIST = 12
const MAX_LABEL = 40
const MAX_PROMPT = 500
function str(v: unknown, max: number): string | undefined {
if (typeof v !== 'string') return undefined
const t = v.trim()
return t && t.length <= max ? t : undefined
}
/** Distinct entries in their first order: a repeated slug would pin one project twice. */
function strList(v: unknown, max: number): string[] | undefined {
if (!Array.isArray(v)) return undefined
const out = [...new Set(v.map((x) => str(x, max)).filter((x): x is string => !!x))]
return out.length ? out.slice(0, MAX_LIST) : undefined
}
/**
* The keys the frontend acts on, shape-checked one by one. A malformed key is dropped, not
* the whole profile: the touch point that skips a survey question must survive a pipeline
* that produced a bad prompt list, and vice versa.
*/
export function parseOnboardingProfile(raw: unknown): OnboardingProfile | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
// Labels are what the home page keys its tags by, so two prompts sharing one would
// break its list: the first wins.
const seen = new Set<string>()
const prompts = Array.isArray(r.starter_prompts)
? r.starter_prompts
.map((p) => {
if (!p || typeof p !== 'object') return undefined
const label = str((p as Record<string, unknown>).label, MAX_LABEL)
const prompt = str((p as Record<string, unknown>).prompt, MAX_PROMPT)
return label && prompt ? { label, prompt } : undefined
})
.filter((p): p is StarterPrompt => !!p && !seen.has(p.label) && !!seen.add(p.label))
.slice(0, MAX_LIST)
: []
const profile: OnboardingProfile = {
touch_point: str(r.touch_point, 200),
company: str(r.company, WORKSPACE_NAME_MAX_LENGTH),
workspace_name: str(r.workspace_name, WORKSPACE_NAME_MAX_LENGTH),
hub_projects: strList(r.hub_projects, 100),
starter_prompts: prompts.length ? prompts : undefined,
tools: strList(r.tools, 50)?.map((t) => t.toLowerCase())
}
return Object.values(profile).some((v) => v !== undefined) ? profile : null
}
let cached: Promise<OnboardingProfile | null> | undefined
let cachedFor: string | undefined
/**
* The profile belongs to a session, not to the page: sign-in and sign-out are client-side
* navigations, so a cache keyed on nothing would hand account A's invite context to
* account B signing in on the same tab. The root layout reports the session's address
* whenever it learns it, and logout clears; a change drops what was cached.
*/
export function noteSessionEmail(email: string | undefined) {
if (email !== cachedFor) {
cached = undefined
cachedFor = email
}
}
/**
* The signed-in account's profile, fetched once per session. Off cloud the server answers
* `null` without a lookup, so the cost is one request per session. A failed fetch reads as
* "no profile" and is retried on the next call rather than cached.
*/
export function onboardingProfile(): Promise<OnboardingProfile | null> {
if (!cached) {
const p = UserService.getOnboardingProfile()
.then((r) => parseOnboardingProfile(r.profile))
.catch((e) => {
console.error('Could not read the onboarding profile:', e)
if (cached === p) cached = undefined
return null
})
cached = p
}
return cached
}
@@ -18,6 +18,9 @@
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
import SidebarUsage from '$lib/components/sidebar/SidebarUsage.svelte'
import SidebarScrollArea from '$lib/components/sidebar/SidebarScrollArea.svelte'
import AccountSetupBanner from '$lib/components/sidebar/AccountSetupBanner.svelte'
import FinishAccountSetup from '$lib/components/sidebar/FinishAccountSetup.svelte'
import { accountSetup } from '$lib/components/sidebar/accountSetup.svelte'
import { SIDEBAR_BG, SIDEBAR_BG_DARK } from '$lib/components/sidebar/sidebarChrome'
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
@@ -869,6 +872,12 @@
// and does not match the store's structural type.
globalS3FilePickerExplorer.val = globalS3FilePicker as any
})
// Whether the account still has to set a password or connect a sign-in decides the
// banner above the nav and the modal below; asked once per page, shared by every reader.
$effect(() => {
if ($userStore) accountSetup.refresh()
})
</script>
<svelte:window bind:innerWidth />
@@ -902,6 +911,18 @@
/>
{/snippet}
<!-- In the rail's pinned footer, right above the plan usage, in both rails and both modes:
an account with no credentials of its own needs to see this wherever it is on the
page and however far the nav has scrolled, and the footer is where the rail already
keeps what is about the account rather than the workspace. -->
{#snippet accountSetupBanner(collapsed: boolean)}
{#if accountSetup.pending}
<div class="px-2 pt-2">
<AccountSetupBanner isCollapsed={collapsed} />
</div>
{/if}
{/snippet}
<!-- Windmill brand mark anchoring the sidebar bottom (the header slot is taken
by the workspace picker). -->
{#snippet brandMark(collapsed: boolean)}
@@ -929,6 +950,13 @@
{/snippet}
<UserSettings bind:this={userSettings} showMcpMode={true} />
{#if accountSetup.pending}
<FinishAccountSetup
bind:open={accountSetup.open}
email={$userStore?.email ?? ''}
onDone={() => accountSetup.refresh()}
/>
{/if}
<DraftMigrationErrorModal />
{#if page.status == 404}
<CenteredModal title="Page not found, redirecting you to login" loading={true}></CenteredModal>
@@ -1101,6 +1129,7 @@
{/if}
<div class="w-52">
{@render accountSetupBanner(false)}
<SidebarUsage isCollapsed={false} />
</div>
@@ -1239,6 +1268,7 @@
{/if}
<div class="flex-shrink-0">
{@render accountSetupBanner(isCollapsed)}
<SidebarUsage {isCollapsed} />
</div>
@@ -25,6 +25,7 @@
MessageCircleCode
} from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { onboardingProfile } from '$lib/onboardingProfile'
// Define step names as constants for better maintainability
const STEP_SOURCE = 'source'
@@ -51,6 +52,25 @@
// The survey was skipped, so the last step has nothing to go back to.
let skippedSurvey = $state(false)
// An invited account arrives with the survey already answered: the invite that brought
// them here is how they heard about us, and their use case was researched before it was
// sent. Neither question is asked; the known source is recorded and they go straight to
// naming their workspace. Resolved before first paint: rendering a survey step and
// yanking it away a frame later reads as a glitch.
let invitedTouchPoint = $state<string | null>(null)
let profileReady = $state(false)
async function loadInviteProfile() {
const profile = await onboardingProfile()
if (profile?.touch_point) {
invitedTouchPoint = profile.touch_point
// An account that already has somewhere to go leaves from here; painting the
// survey behind that navigation would show a step this account never takes.
if (await skip()) return
}
profileReady = true
}
loadInviteProfile()
async function loadWorkspaceStep() {
try {
const [workspaces, invites] = await Promise.all([
@@ -172,30 +192,36 @@
}
}
async function skip() {
/** Declines the survey; true when that left onboarding altogether. */
async function skip(): Promise<boolean> {
isSubmitting = true
try {
// The known source still counts when the rest of the survey is declined.
await UserService.submitOnboardingData({
requestBody: {}
requestBody: invitedTouchPoint ? { touch_point: invitedTouchPoint } : {}
})
} catch (error) {
console.error('Error skipping onboarding:', error)
} finally {
await workspaceStepReady
isSubmitting = false
// Skipping the survey is not skipping naming the workspace: the questions are ours,
// the workspace is theirs.
skippedSurvey = true
if (alreadyPlaced) {
leaveOnboarding()
} else {
currentStep = STEP_WORKSPACE
}
}
await workspaceStepReady
isSubmitting = false
// Skipping the survey is not skipping naming the workspace: the questions are ours,
// the workspace is theirs.
skippedSurvey = true
if (alreadyPlaced) {
await leaveOnboarding()
return true
}
currentStep = STEP_WORKSPACE
return false
}
</script>
{#if currentStep === STEP_SOURCE}
{#if !profileReady}
<!-- While the invite profile, and for an invited account its placement, resolve which
step comes first, or whether there is one at all. -->
<CenteredModal title="Setting things up" loading={true}></CenteredModal>
{:else if currentStep === STEP_SOURCE}
<CenteredModal title="How did you hear about Windmill?">
<div class="w-full max-w-lg mx-auto">
<div class="grid grid-cols-1 gap-2 mt-6 mb-6">
@@ -328,13 +354,16 @@
{/snippet}
</SimpleCreateWorkspace>
<div class="flex justify-center mt-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
{#if !invitedTouchPoint}
<!-- The only step an invited account sees: no progress to show. -->
<div class="flex justify-center mt-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
</div>
</div>
</div>
{/if}
</div>
</CenteredModal>
{/if}
@@ -4,6 +4,7 @@
import { page } from '$app/state'
import { UserService, WorkspaceService } from '$lib/gen'
import { logoutWithRedirect } from '$lib/logoutKit'
import { noteSessionEmail } from '$lib/onboardingProfile'
import {
clearWorkspaceFromStorage,
userStore,
@@ -161,6 +162,7 @@
)
}
let user = await UserService.globalWhoami()
noteSessionEmail(user.email)
console.log(`Welcome back ${user.email}`)
}
} catch (e) {
@@ -29,7 +29,29 @@
const rd = rawRd?.startsWith('http') && !isValidLogoutRedirect(rawRd) ? null : rawRd
const closeUponLogin =
getCookie('close') == 'true' || localStorage.getItem('closeUponLogin') == 'true'
// "Finish account setup" sent a signed-in account with no credentials of its own to a
// provider. Whatever went wrong on the way back — the consent screen cancelled, an
// address mismatch, an unverified address, a domain rule — that session is the only
// way into the account, so it must survive: report and go home rather than log out.
// Read before the backend call, which clears the cookie whether or not it adopts.
// SAML's ACS answers a top-level POST from the IdP, so a refusal there arrives here
// as a redirect with the flag in the query.
const finishingSetup =
!!getCookie('finish_setup') || page.url.searchParams.get('finish_setup') === '1'
function backToSetup(message: string) {
document.cookie = 'finish_setup=; path=/; max-age=0; SameSite=Lax'
sendUserToast(message, true)
goto('/')
}
if (error) {
if (finishingSetup) {
backToSetup(
error.includes('finish_setup_mismatch')
? error.replace(/^.*finish_setup_mismatch:\s*/, '')
: `Signing in with ${clientName} did not go through (${error}). Your account is unchanged.`
)
return
}
sendUserToast(`Error trying to login with ${clientName} ${error}`, true)
if (closeUponLogin) {
closeUponLoginError(`Error trying to login with ${clientName} ${error}`)
@@ -40,6 +62,11 @@
try {
await UserService.loginWithOauth({ requestBody: { code, state }, clientName })
} catch (e) {
const message = String(e?.body ?? e?.message ?? '')
if (finishingSetup) {
backToSetup(message.replace(/^.*finish_setup_mismatch:\s*/, ''))
return
}
if (closeUponLogin) {
closeUponLoginError(e.body ?? e.message)
return
@@ -0,0 +1,23 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { Button } from '$lib/components/common'
import CenteredModal from '$lib/components/CenteredModal.svelte'
const reason = $derived(page.url.searchParams.get('reason'))
const message = $derived(
reason === 'used'
? 'This sign-in link has already been used.'
: reason === 'expired'
? 'This sign-in link has expired.'
: 'This sign-in link is not valid.'
)
</script>
<CenteredModal
title="Sign-in link unavailable"
subtitle="{message} Sign-in links work once and for a few minutes: ask for a new one from where you got this link, or sign in another way."
>
<Button variant="accent" unifiedSize="lg" onClick={() => goto('/user/login')}>Go to login</Button>
</CenteredModal>