Files
windmill/frontend/src/lib/hubProject.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

244 lines
8.9 KiB
TypeScript

import type { Component } from 'svelte'
import { appIconComponent } from '$lib/components/icons'
import { HubPublishService, SettingService } from '$lib/gen'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
/**
* The browser-reachable hub. `hub_accessible_url` exists precisely for this: on a
* private instance `hub_base_url` may be an address only the server can resolve.
* The (logged) layout does the same lookup, but the import wizard renders outside
* it, so it has to ask for itself.
*/
export async function hubBrowserUrl(): Promise<string> {
try {
const accessible = (await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string
if (accessible) return accessible.replace(/\/+$/, '')
const base = (await SettingService.getGlobal({ key: 'hub_base_url' })) as string
if (base) return base.replace(/\/+$/, '')
} catch {
// Unset or unreadable — the public hub is the right default either way.
}
return DEFAULT_HUB_BASE_URL.replace(/\/+$/, '')
}
/** Shape of `GET <hub>/projects/<slug>` — the hub's own summary endpoint. */
interface HubProject {
slug: string
name: string
summary: string
author: string
apps: string[]
logoApp: string | null
hasLogo: boolean
counts: { scripts: number; flows: number; apps: number; resources: number; total: number }
}
/**
* Fetches one project's presentation straight from the hub. Cross-origin and
* unauthenticated by design: this runs before the wizard has a workspace, so the
* workspace-scoped `/api/w/<ws>/hub/...` proxy is not available yet. The endpoint
* is public and sends `Access-Control-Allow-Origin: *`.
*/
export async function fetchHubProject(slug: string): Promise<ImportProjectSummary> {
const hub = await hubBrowserUrl()
const res = await fetch(`${hub}/projects/${encodeURIComponent(slug)}`, {
headers: { accept: 'application/json' }
})
if (!res.ok) throw new Error(`hub returned ${res.status}`)
const p = (await res.json()) as HubProject
return {
slug: p.slug,
name: p.name,
summary: p.summary,
author: p.author,
apps: p.apps ?? [],
// A project with an uploaded logo shows that; otherwise the icon of the
// integration it is filed under, otherwise its first integration.
logoUrl: p.hasLogo ? `${hub}/projects/${encodeURIComponent(p.slug)}/logo` : undefined,
iconApps: [p.logoApp, ...(p.apps ?? [])].filter(
(a, i, all): a is string => !!a && all.indexOf(a) === i
),
counts: {
apps: p.counts?.apps ?? 0,
flows: p.counts?.flows ?? 0,
scripts: p.counts?.scripts ?? 0,
resources: p.counts?.resources ?? 0
}
}
}
/**
* The icon for a hub integration slug, resolved from the icons Windmill already bundles.
*
* Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which
* is this frontend's own package, so asking it over HTTP is a round trip to get our own
* assets back — and it made the card depend on a cross-origin request that an `API_SECRET`
* hub refuses anyway.
*
* The alias exists because the two repos disagree on one slug: the hub files Postgres scripts
* under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in
* `aliasApp`; this is the same bridge on the consuming side.
*/
const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
export function hubAppIcon(app: string): Component | undefined {
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
}
/** One row of the hub's catalogue (`GET <hub>/projects`), which carries no item counts. */
interface HubProjectListRow {
slug: string
name: string
summary: string
description: string
readme: string
author: string
apps: string[]
hasLogo: boolean
stars: number
}
const DESCRIPTION_MAX = 320
/**
* What a project says about itself, in prose.
*
* The hub's `description` field is empty on every published project — the writing all
* goes in the readme — so the readme's opening paragraphs stand in. Everything from the
* first heading onwards is dropped: that is the "Windmill concepts demonstrated" /
* "Usage" material, which is documentation rather than a description. A readme that
* *starts* with a heading (`## Description`) has it skipped rather than treated as the
* end of the intro.
*/
export function hubProjectDescription(row: {
description?: string
readme?: string
summary?: string
}): string {
if (row.description?.trim()) return row.description.trim()
const lines = (row.readme ?? '').split('\n')
let i = 0
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++
const intro: string[] = []
for (; i < lines.length; i++) {
if (lines[i].startsWith('#')) break
intro.push(lines[i])
}
const text = intro
.join(' ')
// Inline markdown only — the block syntax is already gone with the headings.
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (!text) return row.summary?.trim() ?? ''
if (text.length <= DESCRIPTION_MAX) return text
// Cut on a word boundary: a description sliced mid-word reads as corrupted rather
// than shortened.
const cut = text.slice(0, DESCRIPTION_MAX)
const lastSpace = cut.lastIndexOf(' ')
return `${(lastSpace > DESCRIPTION_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`
}
/**
* A card in the template picker. Everything it shows comes from the catalogue listing,
* so a whole page of cards costs one request; the item counts, which only the import
* step needs, are fetched per project by `fetchHubProject` when one is picked.
* `id` is what `InfiniteList` dedupes rows by.
*/
export interface HubProjectPick {
id: string
slug: string
name: string
summary: string
description: string
author: string
apps: string[]
logoUrl?: string
iconApps: string[]
stars: number
}
let catalogue: { workspace: string; projects: Promise<HubProjectPick[]> } | undefined
/**
* Every published project, most-starred first, fetched once per workspace and held for
* the life of the page.
*
* Through the workspace-scoped proxy rather than straight at the hub the way
* `fetchHubProject` goes: the catalogue endpoint sends no `Access-Control-Allow-Origin`,
* so the browser cannot read it directly.
*/
export function hubProjectCatalogue(workspace: string): Promise<HubProjectPick[]> {
if (catalogue?.workspace !== workspace) {
const projects = loadCatalogue(workspace).catch((e) => {
// A cached rejection would make the failure permanent for the whole session;
// dropping it lets the next open try again.
if (catalogue?.projects === projects) catalogue = undefined
throw e
})
catalogue = { workspace, projects }
}
return catalogue.projects
}
async function loadCatalogue(workspace: string): Promise<HubProjectPick[]> {
const raw = await HubPublishService.listHubProjects({ workspace })
const rows = ((typeof raw === 'string' ? JSON.parse(raw) : raw)?.projects ??
[]) as HubProjectListRow[]
const hub = await hubBrowserUrl()
return rows
.map((row) => ({
id: row.slug,
slug: row.slug,
name: row.name,
summary: row.summary,
description: hubProjectDescription(row),
author: row.author,
apps: row.apps ?? [],
logoUrl: row.hasLogo ? `${hub}/projects/${encodeURIComponent(row.slug)}/logo` : undefined,
iconApps: row.apps ?? [],
stars: row.stars ?? 0
}))
.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 }))
]
}