mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Fable 5.1
windmill-internal-app[bot]
parent
0b1e9c0dda
commit
91e6dc39ce
@@ -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.
|
||||
|
||||
+18
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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 @@
|
||||
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()
|
||||
);
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 page’s 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 page’s 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user