mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
* 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>
244 lines
8.9 KiB
TypeScript
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 }))
|
|
]
|
|
}
|