Files
windmill/frontend/src/lib/components/sidebar/UserMenu.svelte
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

188 lines
5.3 KiB
Svelte

<script lang="ts">
import { goto } from '$lib/navigation'
import { logout } from '$lib/logoutKit'
import {
userStore,
usageStore,
workspaceUsageStore,
isPremiumStore,
workspaceStore
} from '$lib/stores'
import { USER_SETTINGS_HASH } from './settings'
import { isCloudHosted } from '$lib/cloud'
import { twMerge } from 'tailwind-merge'
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'
import { type MenubarBuilders } from '@melt-ui/svelte'
import { EXECUTIONS_HINT } from './executionsHint'
let darkMode: boolean = $state(false)
interface Props {
isCollapsed?: boolean
lightMode?: boolean
createMenu: MenubarBuilders['createMenu']
}
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',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-xs"
icon={User}
label={`User (${$userStore?.username ?? $userStore?.email})`}
{isCollapsed}
{lightMode}
showChevron
{trigger}
/>
{/snippet}
{#snippet children({ item })}
<div class="px-4 py-3 border-b" role="none">
<p class="text-sm font-medium text-primary truncate" role="none">
{$userStore?.email}
</p>
<span class="text-xs text-primary flex flex-row gap-2 items-center">
{#if $userStore?.non_member}
Superadmin, not a member of this workspace <Crown size={14} />
{:else if $userStore?.is_admin}
Admin of this workspace <Crown size={14} />
{:else if $userStore?.operator}
Operator in this workspace <ServerCog size={14} />
{/if}
</span>
{#if $userStore?.non_member}
<span class="text-xs text-tertiary block mt-1">
You are not a member, but as a superadmin you can access this workspace. You act here
under the username
<span class="font-mono font-medium text-primary">{$userStore?.username}</span>
with admin permissions.
</span>
{/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
</MenuItem>
<MenuItem
onClick={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
class={itemClass}
{item}
>
{#if darkMode}
<Sun size={16} />
{:else}
<Moon size={16} />
{/if}
Switch theme
</MenuItem>
<MenuItem onClick={() => logout()} class={itemClass} {item}>
<LogOut size={16} />
Sign out
</MenuItem>
</div>
<!-- Both branches below assert a plan, so neither may render for an unresolved or
failed tier: `{:else}` would read `undefined` as paid and claim "Premium plan". -->
{#if isCloudHosted() && $isPremiumStore !== undefined}
<div class="border-t">
{#if $isPremiumStore === false}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$usageStore ?? '—'}/1000 user execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div
class="bg-blue-400 h-full"
style="width: {Math.min($usageStore ?? 0, 1000) / 10}%"
></div>
</div>
</div>
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$workspaceUsageStore ?? '—'}/1000 free workspace execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div
class="bg-blue-400 h-full"
style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%"
></div>
</div>
</div>
{/if}
{#if $userStore?.is_admin}
<MenuItem
class={twMerge(itemClass, 'py-2')}
onClick={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Upgrade
</MenuItem>
{/if}
{:else}
<MenuItem
class={twMerge(itemClass, 'py-2')}
onClick={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Premium plan
</MenuItem>
{/if}
</div>
{/if}
{/snippet}
</Menu>
<DarkModeObserver bind:darkMode />