mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
Claude-Session: https://claude.ai/code/session_015hBkusKsmiRiZ9VZVE314G Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
130 lines
5.7 KiB
TypeScript
130 lines
5.7 KiB
TypeScript
/**
|
|
* The instance policy around creating a workspace, and what the client has to do
|
|
* once one exists. Shared by every screen that creates workspaces — the workspace
|
|
* settings creator and the hub import wizard — so the two cannot drift apart on
|
|
* who is allowed to create, what username the backend expects, or what state the
|
|
* client is left in afterwards.
|
|
*
|
|
* Id validation lives next door in `$lib/utils/workspaceId`, which has no imports
|
|
* and so can be used from anywhere.
|
|
*/
|
|
|
|
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
|
|
import { usersWorkspaceStore } from '$lib/stores'
|
|
import { switchWorkspace } from '$lib/storeUtils'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { base } from '$lib/base'
|
|
import { WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId'
|
|
|
|
/**
|
|
* Whether this user may create a workspace at all. Self-hosted instances default
|
|
* `CREATE_WORKSPACE_REQUIRE_SUPERADMIN` to true, so offering the choice to
|
|
* everyone ends in a 403 at the last step.
|
|
*
|
|
* Superadmin arrives asynchronously in most callers, so pass the current value and
|
|
* call again when it flips. When the gate is on, the server is asked directly
|
|
* rather than trusting that value: `refreshSuperadmin` skips its fetch once the
|
|
* store holds anything, so a page loaded logged out leaves it `false` for the rest
|
|
* of the session — including right after signing in.
|
|
*/
|
|
export async function canCreateWorkspace(isSuperadmin: boolean): Promise<boolean> {
|
|
if (isSuperadmin || isCloudHosted()) return true
|
|
try {
|
|
const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin')
|
|
if ((await r.text()) != 'true') return true
|
|
return !!(await UserService.globalWhoami()).super_admin
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface UsernamePolicy {
|
|
/** When true the backend derives the username and rejects one sent explicitly. */
|
|
automate: boolean
|
|
/** A username to prefill with, when the caller has to ask for one. */
|
|
suggested?: string
|
|
}
|
|
|
|
/** What `usr.username` holds, and neither the provider name nor the email is bounded by it. */
|
|
const USERNAME_MAX_LENGTH = 50
|
|
|
|
/**
|
|
* A username the whole `usr.username` contract accepts: the `proper_username` constraint
|
|
* (`^[\w-]+$`, so word characters and hyphens and nothing else) and the column's own 50
|
|
* characters. Anything outside the class is dropped rather than substituted — `O'Connor` is
|
|
* `oconnor`, not `o-connor`.
|
|
*
|
|
* Undefined where nothing usable is left or where what is left is too long, which is the
|
|
* caller's cue to ask for one: `create_workspace` inserts this value with no truncation, so a
|
|
* name the column refuses would fail on insert with nothing on screen naming the field.
|
|
*/
|
|
export function usernameFromName(name: string): string | undefined {
|
|
const cleaned = name.toLowerCase().replace(/[^\w-]/g, '')
|
|
return cleaned === '' || cleaned.length > USERNAME_MAX_LENGTH ? undefined : cleaned
|
|
}
|
|
|
|
/**
|
|
* `createWorkspace` rejects a username when the instance automates them and
|
|
* requires one when it does not, so the field only exists in the second case.
|
|
*/
|
|
export async function loadUsernamePolicy(): Promise<UsernamePolicy> {
|
|
// Rejects rather than defaulting when the setting cannot be read, because neither
|
|
// default is safe: `create_workspace` refuses a username on an instance that automates
|
|
// them and requires one on an instance that does not (`workspaces.rs:5820`). A caller
|
|
// that cannot read this cannot pick a request shape, and must say so instead of posting
|
|
// one of the two the server rejects.
|
|
const automate =
|
|
((await SettingService.getGlobal({
|
|
key: 'automate_username_creation'
|
|
})) as boolean | null) ?? true
|
|
if (automate) return { automate: true }
|
|
try {
|
|
const me = await UserService.globalWhoami()
|
|
const from = me.name ? me.name.split(' ')[0] : me.email.split('@')[0]
|
|
return { automate: false, suggested: usernameFromName(from) }
|
|
} catch {
|
|
return { automate: false }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-reads the workspaces this user belongs to. Anything that creates or deletes a
|
|
* workspace owes the client this call: `usersWorkspaceStore` is what the picker and
|
|
* every derived workspace list read from, and nothing else refreshes it until a
|
|
* full page load.
|
|
*/
|
|
export async function refreshWorkspaceList(): Promise<void> {
|
|
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
|
}
|
|
|
|
/** Enters a workspace that was just created, with the list refreshed to match. */
|
|
export async function enterNewWorkspace(id: string): Promise<void> {
|
|
await refreshWorkspaceList()
|
|
switchWorkspace(id)
|
|
}
|
|
|
|
/**
|
|
* How long a screen that hands over to a workspace stays up, whatever the server does.
|
|
* Creating or naming one takes a few hundred milliseconds, and a button that swaps the page in
|
|
* that time reads as nothing having happened — the floor is what makes it read as an action
|
|
* that ran, and it covers the workspace layout's first load on the other side.
|
|
*/
|
|
export const WORKSPACE_HANDOVER_MS = 500
|
|
|
|
/**
|
|
* What to call a workspace before its owner has said. The login provider's name when it gave
|
|
* one, else the email local part read as a name: `bob@…` is Bob, `ada.lovelace@…` is Ada
|
|
* Lovelace. Capped at what `create_workspace` accepts, since it is prefilled rather than
|
|
* typed and a name the server would reject must never appear in the field.
|
|
*/
|
|
export function defaultWorkspaceName(name: string | undefined, email: string | undefined): string {
|
|
const display = (name?.trim() || (email ?? '').split('@')[0])
|
|
.split(/[._\-+\s]+/)
|
|
.filter(Boolean)
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ')
|
|
.trim()
|
|
const proposed = display ? `${display}'s workspace` : 'My workspace'
|
|
return proposed.length > WORKSPACE_NAME_MAX_LENGTH ? 'My workspace' : proposed
|
|
}
|