Files
windmill/frontend/src/lib/onboardingProfile.ts
T
91e6dc39ce 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>
2026-09-14 20:04:58 +00:00

117 lines
4.5 KiB
TypeScript

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
}