Files
windmill/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte
T
fce635d3c4 feat: guest app execution mode, a role that takes no seat (#10929)
* feat: guest app execution mode, a fourth role that takes no seat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: make the guest grant a server-minted label, not a declarable scope

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the guest session companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest tokens are not rescopable and guest embed tokens keep the sentinel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest discovery fails closed; SAML aborts if the guest cookie write fails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest may use anonymous apps; await the stale-session logout; trim comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival

* fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode

* fix: a guest upload needs an app policy; a missing app does not skip the confinement

* fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref

* fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch

* fix: guest controls read the plan, not the key; gate the guest tests on the features they need

* docs: tighten the guest session invariant comments

* feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest_activity follows a workspace rename and goes with a workspace delete

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the state-bound guest target

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref; the guest cookie is never cleared by a callback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* test: the seeded allowance is a day old, so only the mint can write today's guest_activity row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

This commit updates the EE repository reference after PR #769 was merged in windmill-ee-private.

Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439

New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-04 22:47:28 +02:00

610 lines
22 KiB
Svelte

<script lang="ts">
import { Alert } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { untrack } from 'svelte'
import { AppService, SettingService, WorkspaceService } from '$lib/gen'
import type { GuestUsage } from '$lib/gen'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Path from '$lib/components/Path.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
import { base } from '$lib/base'
import { isCloudHosted } from '$lib/cloud'
import EEOnly from '$lib/components/EEOnly.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import LabelsInput from '$lib/components/LabelsInput.svelte'
import OnBehalfOfSelector, {
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
let {
policy,
setPublishState,
appPath,
customPath = $bindable(),
onLatest,
savedApp,
summary = $bindable(),
deploymentMsg = $bindable(),
customPathError = $bindable(),
pathError = $bindable(),
newEditedPath = $bindable(),
newPath,
hideSecretUrl = false,
preserveOnBehalfOf = $bindable(false),
labels = $bindable(),
rawApp = false,
newApp = false,
operatingWorkspace = undefined
}: {
policy: any
setPublishState: (message?: string) => void
appPath: string
customPath: string | undefined
onLatest: boolean
savedApp: any
summary: string
deploymentMsg: string | undefined
customPathError: string
pathError: string
newEditedPath: string
newPath: string
hideSecretUrl?: boolean
preserveOnBehalfOf?: boolean
labels?: string[] | undefined
// Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic
// (low-code) apps must NOT get the flag — it would force COEP on the
// document and break no-CORP cross-origin subresources (external images,
// {@html} embeds, CDN imports).
rawApp?: boolean
/** True while the editor is on a draft-only URL (`/edit/u/{user}/draft_{uuid}`
* with no deployed row yet). Suppresses the public-secret-URL fetch
* (`/secret_of/...` 404s with no `app` row) and renders a placeholder
* instead of the eternally-spinning link. */
newApp?: boolean
/** Workspace the app is deployed to — the session's acting workspace when
* embedded in a session preview, else the navigation `$workspaceStore`.
* The secret-URL / custom-path / folder / on-behalf-of lookups must target
* it, not `$workspaceStore` (which stays on the nav workspace in a session). */
operatingWorkspace?: string
} = $props()
const opWs = $derived(operatingWorkspace ?? $workspaceStore)
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
// Admins always pass the backend check. For everyone else, fail closed
// while the workspace protection rules are still loading so the toggle
// is never briefly enabled for a user the rules will end up restricting.
let rulesetsLoaded = $derived(protectionRulesState.rulesets !== undefined)
let canSetAnonymous = $derived(
!!$userStore?.is_admin ||
!!$userStore?.is_super_admin ||
(rulesetsLoaded &&
canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined))
)
let canSetGuest = $derived(
!!$userStore?.is_admin ||
!!$userStore?.is_super_admin ||
(rulesetsLoaded &&
canUserBypassRuleKind('RestrictGuestAppDeployment', $userStore ?? undefined))
)
// The three rungs of the access control, widest last. `viewer` is a fourth
// execution mode that this control never sets (it runs components as the viewer,
// which a guest cannot be), so an app in it shows as members-only here.
let accessMode = $derived(
policy.execution_mode == 'anonymous'
? 'anonymous'
: policy.execution_mode == 'guest'
? 'guest'
: 'publisher'
)
// Undefined until loaded. An app can be set to `guest` while the workspace has
// guests off, in which case the mode is stored but inert -- say so rather than
// letting the publisher believe the app is open.
let guestAccessEnabled: boolean | undefined = $state(undefined)
let guestUsage: GuestUsage | undefined = $state(undefined)
$effect(() => {
const ws = opWs
if (ws === undefined) return
untrack(() => {
WorkspaceService.getPublicSettings({ workspace: ws })
.then((s) => (guestAccessEnabled = s.guest_access_enabled))
.catch(() => (guestAccessEnabled = undefined))
WorkspaceService.getGuestUsage({ workspace: ws })
.then((u) => (guestUsage = u))
.catch(() => (guestUsage = undefined))
})
})
function onAccessModeChange(mode: string | undefined) {
if (mode === undefined || mode === accessMode) return
policy.execution_mode = mode
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so
// `setPublishState` would 404. The mode is carried by the first deploy's
// policy; persist incrementally only once the app exists.
if (savedApp && !newApp) {
setPublishState()
}
}
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email)
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
let customOnBehalfOfEmail: string = $state('')
let dirtyCustomPath = $state(false)
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
async function appExists(customPath: string) {
return await AppService.customPathExists({
workspace: opWs!,
customPath
})
}
let globalWorkspacedRoute = $state(false)
async function loadGlobalWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'app_workspaced_route' })
globalWorkspacedRoute = (setting as boolean) ?? false
} catch (error) {
globalWorkspacedRoute = false
}
}
loadGlobalWorkspacedRouteSetting()
let secretUrl: string | undefined = $state(undefined)
let secretUrlHref = $derived(secretUrl ? computeSecretUrl(secretUrl) : undefined)
let fullCustomUrl = $derived(
`${window.location.origin}${base}/a/${
isCloudHosted() || globalWorkspacedRoute ? opWs + '/' : ''
}${customPath}`
)
// When embedding a raw app in an iframe inside another Windmill app (or any
// cross-origin-isolated page), the embedded document must set COEP. The
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
// Only raw apps get it — for classic (low-code) apps COEP would break
// no-CORP cross-origin subresources, so their snippet stays a plain iframe.
let embedMode = $state(false)
function toEmbedSnippet(url: string): string {
const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url
return `<iframe src="${finalUrl}" title="Windmill app" width="100%" height="600"></iframe>`
}
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: opWs!,
path: appPath
})
}
let validateTimeout: number | undefined = undefined
async function validateCustomPath(customPath: string): Promise<void> {
customPathError = ''
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
customPathError = 'Invalid path'
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
customPathError = 'Path already taken'
} else {
customPathError = ''
}
validateTimeout = undefined
}, 500)
}
$effect(() => {
;[customPath]
untrack(() => customPath !== undefined && validateCustomPath(customPath))
})
$effect(() => {
// Skip the secret URL fetch on draft-only items — `/secret_of/...`
// has no `app` row to look up and would 404, leaving the UI
// component spinning indefinitely.
!newApp &&
appPath &&
appPath != '' &&
savedApp &&
secretUrl == undefined &&
untrack(() => getSecretUrl())
})
</script>
{#if !onLatest}
<Alert title="You're not on the latest app version. " type="warning">
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
</Alert>
<div class="py-2"></div>
{/if}
<label for="summary" class="text-emphasis text-xs font-semibold">Summary</label>
<div class="w-full pt-1">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
id: 'summary',
autofocus: true,
placeholder: 'App summary',
onkeydown: (e) => {
e.stopPropagation()
},
onkeyup: () => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}
}}
bind:value={summary}
/>
</div>
<div class="pt-3"></div>
<LabelsInput bind:labels class="-mt-4" />
<div class="py-6"></div>
<label for="deploymentMsg" class="text-emphasis text-xs font-semibold">Deployment message</label>
<div class="w-full pt-1">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
id: 'deploymentMsg',
placeholder: 'Optional deployment message'
}}
bind:value={deploymentMsg}
/>
</div>
<div class="py-6"></div>
<label for="path" class="text-emphasis text-xs font-semibold">Path</label>
<Path
bind:this={path}
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
namePlaceholder="app"
kind="app"
autofocus={false}
workspaceOverride={operatingWorkspace}
/>
<div class="py-2"></div>
<Alert title="App executed on behalf of you">
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
<Tooltip>
It ensures that all required resources/runnable visible for publisher but not for viewer at time
of creating the app would prevent the execution of the app. To guarantee tight security, a
policy is computed at time of deployment of the app which only allow the scripts/flows referred
to in the app to be called on behalf of. Furthermore, static parameters are not overridable.
Hence, users will only be able to use the app as intended by the publisher without risk for
leaking resources not used in the app.
</Tooltip>
{#if canPreserve}
<div class="mt-4">
Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another
user to run this app on behalf of. Once deployed the app will be run on behalf of
<OnBehalfOfSelector
targetWorkspace={opWs ?? ''}
targetValue={savedOnBehalfOfEmail}
selected={onBehalfOfChoice}
onSelect={(choice, details) => {
onBehalfOfChoice = choice
if (choice === 'me') {
policy.on_behalf_of_email = $userStore?.email
policy.on_behalf_of = `u/${$userStore?.username}`
customOnBehalfOfEmail = ''
preserveOnBehalfOf = false
} else if (choice === 'target') {
policy.on_behalf_of_email = savedOnBehalfOfEmail
policy.on_behalf_of = savedOnBehalfOf
customOnBehalfOfEmail = ''
preserveOnBehalfOf = true
} else if (choice === 'custom' && details) {
policy.on_behalf_of_email = details.email
policy.on_behalf_of = details.permissionedAs
customOnBehalfOfEmail = details.email
preserveOnBehalfOf = true
}
}}
kind="app"
{canPreserve}
customValue={customOnBehalfOfEmail}
isDeployment={false}
/>
</div>
{/if}
</Alert>
<div class="mt-10"></div>
<div class="flex items-center gap-2">
<h2>Sandbox isolation</h2>
<Badge color="yellow">Alpha</Badge>
</div>
<div class="my-6">
<Toggle
options={{ right: "Isolate the app from the viewer's browser session" }}
checked={policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
// Frontend API access exists only for a sandboxed app, so turning
// isolation off drops the declared scopes with it rather than leaving
// them set but inert.
if (!e.detail) {
policy.frontend_sdk_scopes = undefined
}
// A not-yet-deployed app has no row to PATCH — `setPublishState` (POST
// /apps/update) would 404. The flag rides along in the `policy` the first
// deploy sends (createApp), so here we only mutate it locally. Persist
// incrementally once the app exists.
if (savedApp && !newApp) {
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
}
}}
disabled={!savedApp}
/>
<div class="text-xs text-secondary mt-1">
Controls what the app's browser-side code can reach in each viewer's browser distinct from the
on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code
uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead,
on every surface (public URL and in-workspace). Leave it off if the app needs full browser
features (IndexedDB, third-party auth/SDKs, OAuth redirects).
</div>
{#if newApp}
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
{/if}
{#if policy.sandbox == true}
<div class="mt-2">
<Alert type="warning" title="Alpha feature" size="xs">
Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm
it still works, and report any broken behavior.
</Alert>
</div>
{/if}
</div>
{#if rawApp && policy.sandbox == true}
<h2 class="text-xs font-semibold">Frontend API access</h2>
<div class="mb-6 mt-2">
<div class="text-xs text-secondary mb-3">
Let the app's frontend code call the Windmill API through the <code>windmill-client</code>
SDK, authenticated as <b>the viewer</b> (unlike runnables, which run on behalf of the
publisher). Each viewer is asked to approve the scopes below before the app runs. Grant only
what the app needs: its code — or an XSS bug in it — can use them as that viewer. Add
<code>windmill-client</code> to the app's dependencies to import it; it configures itself from
the token handed to the bundle.
</div>
{#each FRONTEND_SDK_SCOPES as scope (scope.value)}
<div class="mb-2">
<Toggle
size="xs"
options={{ right: scope.label }}
checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false}
on:change={(e) => {
const current: string[] = policy.frontend_sdk_scopes ?? []
const next = e.detail
? [...current, scope.value]
: current.filter((s) => s !== scope.value)
// Keep the curated order so the consent banner and the stored
// consent compare stably across deploys.
const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s))
policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so the
// scopes ride along in the first deploy's policy instead.
if (savedApp && !newApp) {
setPublishState('Frontend API access updated')
}
}}
disabled={!savedApp}
/>
<div class="text-xs text-hint ml-9">{scope.description}</div>
</div>
{/each}
{#if newApp}
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
{/if}
{#if policy.frontend_sdk_scopes?.length}
<div class="mt-2">
<Alert type="info" title="Redeploy to use the SDK from a sandboxed app" size="xs">
A sandboxed app calls the API cross-origin, which older <code>windmill-client</code> versions
cannot do. An app bundled before this Windmill version fails with a CORS error until you deploy
it again, which re-bundles it against a current client.
</Alert>
</div>
{/if}
</div>
{/if}
{#if !hideSecretUrl}
<h2>Access</h2>
<div class="my-6">
{#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to anyone with the link is restricted to workspace admins and bypass users
by a workspace protection rule
</Alert>
<div class="mb-2"></div>
{/if}
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
protection rule
</Alert>
<div class="mb-2"></div>
{/if}
<div class="flex gap-2 items-center mb-2">
<ToggleButtonGroup
selected={accessMode}
on:selected={(e) => onAccessModeChange(e.detail)}
disabled={!savedApp}
>
{#snippet children({ item })}
<ToggleButton
label="Members"
value="publisher"
tooltip="Workspace members with read access on this app."
{item}
/>
<ToggleButton
label="Guests"
value="guest"
disabled={!canSetGuest && policy.execution_mode != 'guest'}
tooltip="Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."
{item}
/>
<ToggleButton
label="Public"
value="anonymous"
disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'}
tooltip="Anyone with the secret URL. No login."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="text-xs text-secondary mb-3">
{#if policy.execution_mode == 'anonymous'}
Anyone holding the secret URL below can open this app without signing in.
{:else if policy.execution_mode == 'guest'}
{#if guestUsage && !guestUsage.instance_enabled}
A superadmin has turned guests off for this instance, so this app still admits members
only.
{:else if guestAccessEnabled === undefined}
Checking whether this workspace allows guests
{:else if guestAccessEnabled === false}
Guests are turned off for this workspace, so this app still admits members only. A
workspace admin can turn them on in the workspace settings.
{:else}
Anyone your identity provider authenticates can open this app without a Windmill account.
They join no workspace. Members of this workspace can open it too.
{#if guestUsage}
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
? 'every four guests count as one seat'
: 'new guests are refused until the count drops'}.
{/if}
{/if}
{:else}
Only workspace members with read access on this app can open it.
{/if}
</div>
{#if !savedApp || newApp}
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<div class="flex justify-end mb-1">
<Toggle
size="xs"
checked={embedMode}
on:change={(e) => (embedMode = e.detail)}
options={{ left: 'URL', right: 'Embed' }}
/>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
size="md"
/>
{:else}<Loader2 class="animate-spin" />
{/if}
<div class="text-xs text-secondary mt-1">
{#if embedMode}
Paste this iframe snippet into another app.
{#if rawApp}
The <code>wm_coep</code> flag <Tooltip
>Sets the cross-origin isolation headers (COEP) so the app can be embedded inside
another Windmill app or any cross-origin-isolated page. Without it the browser blocks
the iframe.</Tooltip
> lets it load inside a cross-origin-isolated page.
{/if}
(if requiring login, top-level domain of embedding app must be the same as the one of Windmill)
{:else}
Share this url directly, or switch to <b>Embed</b> to get an iframe snippet.
{/if}
</div>
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
Custom path can only be set by workspace admins
</Alert>
<div class="mb-2"></div>
{/if}
<!-- svelte-ignore block_empty -->
{#if !$enterpriseLicense}
<EEOnly />
{/if}
<Toggle
on:change={({ detail }) => {
customPath = detail ? '' : undefined
if (customPath === undefined) {
customPathError = ''
}
}}
checked={customPath !== undefined}
options={{
right: 'Use a custom URL'
}}
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
/>
{#if customPath !== undefined}
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>Custom path</div>
</div>
<input
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
type="text"
autocomplete="off"
bind:value={customPath}
class={customPathError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
oninput={() => {
dirtyCustomPath = true
}}
/>
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
<div>Custom public URL</div>
</div>
<ClipboardPanel
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
size="md"
/>
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyCustomPath ? customPathError : ''}
</div>
{/if}
</div>
</div>
<Alert type="info" title="Only latest deployed app is publicly available">
You will still need to deploy the app to make visible the latest changes
</Alert>
<a
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
>
{/if}