mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
a89fcf72fa
Support OAuth 2.0 client-credentials authentication for SCIM provisioning (e.g. Microsoft Entra ID) alongside the existing static bearer token, so identity providers can use short-lived, rotatable access tokens. Backend (EE companion PR modifies scim_ee.rs): - Unauthenticated token endpoint POST /api/scim_token/token issues a short-lived scope:scim JWT via the client-credentials grant. - has_scim_token validates SCIM JWTs (when OAuth is configured) in addition to the static token — fully backward compatible. - Super-admin config endpoints (generate/rotate/disable) store the client secret hashed (SHA-256). New scim_oauth global setting is agent-worker blocked and live-reloaded. Frontend: - OAuth 2.0 client-credentials section in the SCIM/SAML instance settings: enable toggle, generate-secret (shown once), copyable token endpoint and client ID, regenerate/disable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1257 lines
41 KiB
Svelte
1257 lines
41 KiB
Svelte
<script lang="ts">
|
|
import { scimSamlSetting, settings, settingsKeys } from './instanceSettings'
|
|
import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common'
|
|
import { SettingService, SettingsService } from '$lib/gen'
|
|
import type { TeamsChannel } from '$lib/gen/types.gen'
|
|
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { deepEqual } from 'fast-equals'
|
|
|
|
import { sleep } from '$lib/utils'
|
|
import { enterpriseLicense } from '$lib/stores'
|
|
|
|
import { createEventDispatcher } from 'svelte'
|
|
import { setLicense } from '$lib/enterpriseUtils'
|
|
import AuthSettings from './AuthSettings.svelte'
|
|
import ScimOauthSettings from './ScimOauthSettings.svelte'
|
|
import oauthConnectRegistry from '$oauth_connect_registry'
|
|
import InstanceSetting from './InstanceSetting.svelte'
|
|
import { writable, type Writable } from 'svelte/store'
|
|
import { ExternalLink, Loader2 } from 'lucide-svelte'
|
|
import YAML from 'yaml'
|
|
import Toggle from './Toggle.svelte'
|
|
import SettingsFooter from './workspaceSettings/SettingsFooter.svelte'
|
|
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
|
|
import WorkspaceRegistries from './instanceSettings/WorkspaceRegistries.svelte'
|
|
import DbHealth from './instanceSettings/DbHealth.svelte'
|
|
|
|
interface Props {
|
|
tab?: string
|
|
hideTabs?: boolean
|
|
closeDrawer?: (() => void) | undefined
|
|
authSubTab?: 'sso' | 'oauth' | 'scim'
|
|
onNavigateToTab?: (category: string) => void
|
|
quickSetup?: boolean
|
|
yamlMode?: boolean
|
|
hasUnsavedChanges?: boolean
|
|
hasAnyInvalid?: boolean
|
|
}
|
|
|
|
let {
|
|
tab = $bindable('Core'),
|
|
hideTabs = false,
|
|
closeDrawer = () => {},
|
|
authSubTab = $bindable('sso'),
|
|
onNavigateToTab,
|
|
quickSetup = false,
|
|
yamlMode = $bindable(false),
|
|
hasUnsavedChanges = $bindable(false),
|
|
hasAnyInvalid = $bindable(false)
|
|
}: Props = $props()
|
|
|
|
let values: Writable<Record<string, any>> = writable({})
|
|
let initialOauths: Record<string, any> = $state({})
|
|
let initialRequirePreexistingUserForOauth: boolean = $state(false)
|
|
let requirePreexistingUserForOauth: boolean = $state(false)
|
|
|
|
let initialValues: Record<string, any> = $state({})
|
|
let baseUrlIsFallback = $state(false)
|
|
// Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name
|
|
// keyed by provider, used to build their per-instance connect_config URLs.
|
|
let instanceInputs: Record<string, string> = $state({})
|
|
let version: string = $state('')
|
|
let loading = $state(true)
|
|
|
|
export function getVersion(): string {
|
|
return version
|
|
}
|
|
|
|
export function getLicenseKey(): string {
|
|
return $values?.['license_key'] ?? ''
|
|
}
|
|
|
|
loadSettings()
|
|
loadVersion()
|
|
|
|
// When the user enables object storage for the first time, default
|
|
// `monitor_logs_on_s3` to true so S3 log files get cleaned up with their
|
|
// jobs. Backend still defaults to false for backwards compat with
|
|
// operators who never touched the setting.
|
|
$effect(() => {
|
|
if ($values['object_store_cache_config'] && $values['monitor_logs_on_s3'] === undefined) {
|
|
values.update((v) => ({ ...v, monitor_logs_on_s3: true }))
|
|
}
|
|
})
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
async function loadVersion() {
|
|
version = await SettingsService.backendVersion()
|
|
}
|
|
let oauths: Record<string, any> = $state({})
|
|
|
|
/** Ensure object/array-typed settings have a non-null default for the form UI */
|
|
const formDefaults: Record<string, any> = {
|
|
smtp_settings: {},
|
|
otel: {},
|
|
indexer_settings: {},
|
|
critical_error_channels: [],
|
|
github_enterprise_app: {}
|
|
}
|
|
|
|
function applyFormDefaults(vals: Record<string, any>): void {
|
|
for (const [key, defaultVal] of Object.entries(formDefaults)) {
|
|
if (vals[key] == undefined) {
|
|
vals[key] =
|
|
typeof defaultVal === 'object' ? JSON.parse(JSON.stringify(defaultVal)) : defaultVal
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadSettings() {
|
|
loading = true
|
|
|
|
// Bulk-load all settings in a single API call
|
|
const config = await SettingService.getInstanceConfig()
|
|
const gs = (config.global_settings ?? {}) as Record<string, any>
|
|
|
|
initialOauths = gs['oauths'] ?? {}
|
|
requirePreexistingUserForOauth = gs['require_preexisting_user_for_oauth'] ?? false
|
|
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
|
oauths = JSON.parse(JSON.stringify(initialOauths))
|
|
|
|
let nvalues: Record<string, any> = { ...gs }
|
|
|
|
baseUrlIsFallback = !nvalues['base_url']
|
|
if (nvalues['retention_period_secs'] == undefined) {
|
|
nvalues['retention_period_secs'] = 60 * 60 * 24 * 30
|
|
}
|
|
applyFormDefaults(nvalues)
|
|
|
|
// Apply select/select_python defaults so initialValues matches what InstanceSetting's $effect does
|
|
for (const category of settingsKeys) {
|
|
for (const s of settings[category]) {
|
|
if (
|
|
(s.fieldType === 'select' || s.fieldType === 'select_python') &&
|
|
nvalues[s.key] == undefined &&
|
|
s.defaultValue
|
|
) {
|
|
nvalues[s.key] = s.defaultValue()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Snapshot initialValues before applying the base_url fallback so that
|
|
// the dirty-check detects the unsaved default and enables the Save button.
|
|
initialValues = JSON.parse(JSON.stringify(nvalues))
|
|
|
|
if (baseUrlIsFallback) {
|
|
nvalues['base_url'] = window.location.origin
|
|
}
|
|
$values = nvalues
|
|
loading = false
|
|
|
|
// populate per-instance OAuth provider inputs (snowflake, servicenow, …) from db
|
|
loadInstanceInputs(oauths)
|
|
}
|
|
|
|
export async function saveSettings() {
|
|
if (yamlMode) {
|
|
if (!syncYamlToForm()) {
|
|
return
|
|
}
|
|
}
|
|
|
|
setupTemplatedOauthUrls()
|
|
|
|
// Remove empty or invalid entries for critical error channels
|
|
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
|
|
if (!entry || typeof entry !== 'object') return false
|
|
if ('teams_channel' in entry) {
|
|
return isValidTeamsChannel(entry.teams_channel)
|
|
}
|
|
if ('slack_channel' in entry) {
|
|
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
|
|
}
|
|
if ('email' in entry) {
|
|
return typeof entry.email === 'string' && entry.email.trim() !== ''
|
|
}
|
|
// Unknown shape
|
|
return false
|
|
})
|
|
|
|
let shouldReloadPage = false
|
|
let willRestart = false
|
|
if ($values) {
|
|
// Trim license key before saving
|
|
if ($values['license_key'] && typeof $values['license_key'] === 'string') {
|
|
$values['license_key'] = $values['license_key'].trim()
|
|
}
|
|
|
|
// Check which settings require a page reload or server restart
|
|
const allSettings = [...Object.values(settings), scimSamlSetting].flat()
|
|
let licenseKeySet = false
|
|
for (const s of allSettings) {
|
|
if (s.storage === 'setting' && !deepEqual(initialValues?.[s.key], $values?.[s.key])) {
|
|
if (s.key === 'license_key') {
|
|
licenseKeySet = true
|
|
}
|
|
if (s.requiresReloadOnChange) {
|
|
shouldReloadPage = true
|
|
}
|
|
if (s.triggersRestart) {
|
|
willRestart = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build the full global_settings object for the bulk PUT
|
|
const globalSettings: Record<string, any> = { ...$values }
|
|
|
|
// Send explicit null for keys that were set on load but are now
|
|
// missing/cleared, so the Merge-mode bulk endpoint deletes them
|
|
// instead of silently preserving the old DB value. Covers both:
|
|
// - YAML mode: user removed a line / set it to null / set it to {}.
|
|
// - Form mode: user toggled a setting off (e.g.
|
|
// object_store_cache_config), making the value undefined.
|
|
const isClearedValue = (v: any) => {
|
|
if (v === undefined || v === null) return true
|
|
if (typeof v === 'object') {
|
|
return Array.isArray(v) ? v.length === 0 : Object.keys(v).length === 0
|
|
}
|
|
return false
|
|
}
|
|
for (const key of Object.keys(initialValues ?? {})) {
|
|
if (excludedKeys.has(key)) continue
|
|
if (key === 'oauths' || key === 'require_preexisting_user_for_oauth') continue
|
|
if (!isClearedValue(globalSettings[key])) continue
|
|
// Only flag for deletion if it was non-empty before — otherwise
|
|
// every save would delete-then-recreate harmless `{}`/`[]` defaults.
|
|
if (isClearedValue(initialValues[key])) continue
|
|
globalSettings[key] = null
|
|
}
|
|
|
|
// Include oauths and require_preexisting_user_for_oauth
|
|
if (!deepEqual(initialOauths, oauths)) {
|
|
globalSettings['oauths'] = oauths
|
|
}
|
|
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
|
|
globalSettings['require_preexisting_user_for_oauth'] = requirePreexistingUserForOauth
|
|
}
|
|
|
|
await SettingService.setInstanceConfig({
|
|
requestBody: { global_settings: globalSettings }
|
|
})
|
|
|
|
initialValues = JSON.parse(JSON.stringify($values))
|
|
initialOauths = JSON.parse(JSON.stringify(oauths))
|
|
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
|
baseUrlIsFallback = false
|
|
|
|
if (yamlMode) {
|
|
yamlCodeInitial = yamlCode
|
|
}
|
|
|
|
if (licenseKeySet) {
|
|
setLicense()
|
|
}
|
|
} else {
|
|
console.error('Values not loaded')
|
|
}
|
|
if (shouldReloadPage) {
|
|
sendUserToast('Settings updated, reloading page...')
|
|
await sleep(1000)
|
|
window.location.reload()
|
|
} else if (willRestart) {
|
|
sendUserToast(
|
|
'Settings updated. Servers are restarting and changes may take up to a minute to fully propagate.',
|
|
false,
|
|
[],
|
|
undefined,
|
|
8000
|
|
)
|
|
dispatch('saved')
|
|
} else {
|
|
sendUserToast('Settings updated')
|
|
dispatch('saved')
|
|
}
|
|
}
|
|
|
|
// Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name ->
|
|
// their registry connect_config_template. Adding a new one needs only a
|
|
// registry entry — no code here.
|
|
// Every per-instance templated provider is configurable here: authorization-code
|
|
// ones (ServiceNow, Snowflake) provide an `auth_url`, client-credentials-only
|
|
// ones (Coupa) provide only a `token_url`. Both need the admin to enter their
|
|
// instance host so the shared credentials point at the right endpoint.
|
|
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
|
|
Object.entries(oauthConnectRegistry)
|
|
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
|
|
.map(([name, cfg]) => [name, (cfg as any).connect_config_template])
|
|
)
|
|
|
|
function normalizeInstanceInput(tmpl: any, raw: string): string {
|
|
let v = (raw ?? '').replace(/\s/g, '')
|
|
if (tmpl.strip_suffix) {
|
|
// accept a full host/URL or a bare name -> reduce to the bare instance
|
|
v = v.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
|
if (v.endsWith(tmpl.strip_suffix)) {
|
|
v = v.slice(0, -tmpl.strip_suffix.length)
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Build each per-instance provider's connect_config from the admin-entered
|
|
// instance name + its registry template (substituting {instance} into the
|
|
// URLs). Replaces the old per-provider setup functions.
|
|
function setupTemplatedOauthUrls() {
|
|
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
|
|
if (!oauths?.[name]) continue
|
|
const key = tmpl.extra_params_key ?? 'instance'
|
|
const v = normalizeInstanceInput(tmpl, instanceInputs[name] ?? '')
|
|
instanceInputs[name] = v
|
|
if (oauths[name].connect_config?.extra_params?.[key] === v) continue
|
|
oauths[name].connect_config = {
|
|
scopes: tmpl.scopes ?? [],
|
|
// CC-only templated providers have no auth_url; store an empty string
|
|
// (not omitted) so the instance-config parser still types the entry.
|
|
// The backend treats an empty auth_url as the unused placeholder for
|
|
// the client-credentials grant.
|
|
auth_url: tmpl.auth_url ? tmpl.auth_url.replaceAll('{instance}', v) : '',
|
|
token_url: tmpl.token_url.replaceAll('{instance}', v),
|
|
req_body_auth: tmpl.req_body_auth ?? false,
|
|
extra_params: { [key]: v },
|
|
extra_params_callback: {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recover the instance-name inputs from a saved oauths config (for load/discard).
|
|
function loadInstanceInputs(savedOauths: Record<string, any>) {
|
|
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
|
|
const key = tmpl.extra_params_key ?? 'instance'
|
|
instanceInputs[name] = savedOauths?.[name]?.connect_config?.extra_params?.[key] ?? ''
|
|
}
|
|
}
|
|
|
|
let sendingStats = $state(false)
|
|
async function sendStats() {
|
|
try {
|
|
sendingStats = true
|
|
await SettingService.sendStats()
|
|
sendUserToast('Usage sent')
|
|
} catch (err) {
|
|
throw err
|
|
} finally {
|
|
sendingStats = false
|
|
}
|
|
}
|
|
|
|
let downloadingStats = $state(false)
|
|
async function downloadStats() {
|
|
try {
|
|
downloadingStats = true
|
|
const result = await SettingService.getStats()
|
|
const blob = new Blob([result.data ?? ''], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
const date = new Date().toISOString().split('T')[0]
|
|
a.download = `windmill-telemetry-${date}-${result.signature}.json`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
sendUserToast('Telemetry data downloaded')
|
|
} catch (err) {
|
|
throw err
|
|
} finally {
|
|
downloadingStats = false
|
|
}
|
|
}
|
|
|
|
function isValidTeamsChannel(value: any): value is TeamsChannel {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
typeof value.team_id === 'string' &&
|
|
value.team_id.trim() !== '' &&
|
|
typeof value.team_name === 'string' &&
|
|
value.team_name.trim() !== '' &&
|
|
typeof value.channel_id === 'string' &&
|
|
value.channel_id.trim() !== '' &&
|
|
typeof value.channel_name === 'string' &&
|
|
value.channel_name.trim() !== ''
|
|
)
|
|
}
|
|
|
|
function openSmtpSettings() {
|
|
if (onNavigateToTab) {
|
|
onNavigateToTab('SMTP')
|
|
} else {
|
|
tab = 'SMTP'
|
|
}
|
|
}
|
|
|
|
// --- Dirty state tracking (YAML-based) ---
|
|
|
|
function stripEmpty(obj: Record<string, any>): Record<string, any> {
|
|
return Object.fromEntries(
|
|
Object.entries(obj)
|
|
.filter(([_, v]) => v !== undefined && v !== '')
|
|
.map(([k, v]) =>
|
|
v != null && typeof v === 'object' && !Array.isArray(v) ? [k, stripEmpty(v)] : [k, v]
|
|
)
|
|
)
|
|
}
|
|
|
|
function getSettingsForCategory(category: string) {
|
|
if (category === 'Auth/OAuth/SAML') {
|
|
return [...(settings[category] ?? []), ...scimSamlSetting]
|
|
}
|
|
const base = settings[category] ?? []
|
|
// In quick setup, reorder Core: base settings (without license_key), then extras from Jobs
|
|
if (quickSetup && category === 'Core') {
|
|
const licenseKey = base.find((s) => s.key === 'license_key')
|
|
const baseWithout = base.filter((s) => s.key !== 'license_key')
|
|
const jobSettings = settings['Jobs'] ?? []
|
|
const jobIsolation = jobSettings.find((s) => s.key === 'job_isolation')
|
|
const retentionPeriod = jobSettings.find((s) => s.key === 'retention_period_secs')
|
|
const objectStorage = settings['Object Storage']?.find(
|
|
(s) => s.key === 'object_store_cache_config'
|
|
)
|
|
return [
|
|
...baseWithout,
|
|
...(jobIsolation ? [jobIsolation] : []),
|
|
...(licenseKey ? [licenseKey] : []),
|
|
...(retentionPeriod ? [retentionPeriod] : []),
|
|
...(objectStorage ? [objectStorage] : [])
|
|
]
|
|
}
|
|
return base
|
|
}
|
|
|
|
function normalizeValue(value: any, key?: string): any {
|
|
if (value == null) return undefined
|
|
if (value === false) return undefined
|
|
if (typeof value === 'string' && value.trim() === '') return undefined
|
|
if (Array.isArray(value) && value.length === 0) return undefined
|
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
// Recursively normalize: if all values in the object normalize to undefined,
|
|
// the object itself is effectively empty (e.g. {smtp_tls_implicit: false} ≡ {})
|
|
const hasNonEmpty = Object.values(value).some((v) => normalizeValue(v) !== undefined)
|
|
if (!hasNonEmpty) return undefined
|
|
}
|
|
|
|
// Key-specific defaults: these values are equivalent to "not set"
|
|
if (key === 'secret_backend') {
|
|
if (
|
|
typeof value === 'object' &&
|
|
value?.type === 'Database' &&
|
|
Object.keys(value).length === 1
|
|
) {
|
|
return undefined
|
|
}
|
|
}
|
|
if (key === 'automate_username_creation' && value === true) {
|
|
return undefined
|
|
}
|
|
if (key === 'critical_alerts_on_db_oversize' && typeof value === 'object') {
|
|
if (!value.enabled && (!value.value || value.value === 0)) {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
return value
|
|
}
|
|
function buildCategoryYaml(
|
|
category: string,
|
|
vals: Record<string, any>,
|
|
oauthsObj: Record<string, any>,
|
|
reqPreexisting: boolean
|
|
): string {
|
|
const categorySettings = getSettingsForCategory(category)
|
|
const obj: Record<string, any> = {}
|
|
for (const s of categorySettings) {
|
|
const normalized = normalizeValue(vals[s.key], s.key)
|
|
if (normalized !== undefined) {
|
|
obj[s.key] = vals[s.key]
|
|
}
|
|
}
|
|
if (category === 'Auth/OAuth/SAML') {
|
|
if (Object.keys(stripEmpty(oauthsObj)).length > 0) {
|
|
obj['oauths'] = oauthsObj
|
|
}
|
|
if (reqPreexisting) {
|
|
obj['require_preexisting_user_for_oauth'] = reqPreexisting
|
|
}
|
|
}
|
|
if (category === 'Registries') {
|
|
obj['workspace_registries'] = vals['workspace_registries'] ?? null
|
|
}
|
|
return YAML.stringify(obj)
|
|
}
|
|
|
|
let dirtyCategories: Record<string, boolean> = $derived.by(() => {
|
|
const result: Record<string, boolean> = {}
|
|
for (const category of settingsKeys) {
|
|
const initialYaml = buildCategoryYaml(
|
|
category,
|
|
initialValues,
|
|
initialOauths,
|
|
initialRequirePreexistingUserForOauth
|
|
)
|
|
const currentYaml = buildCategoryYaml(
|
|
category,
|
|
$values,
|
|
oauths,
|
|
requirePreexistingUserForOauth
|
|
)
|
|
result[category] = initialYaml !== currentYaml
|
|
}
|
|
return result
|
|
})
|
|
|
|
let invalidCategories: Record<string, boolean> = $derived.by(() => {
|
|
const currentValues = $values
|
|
const result: Record<string, boolean> = {}
|
|
for (const category of settingsKeys) {
|
|
const categorySettings = getSettingsForCategory(category)
|
|
result[category] = categorySettings.some((s) => {
|
|
if (s.isValid && !s.isValid(currentValues?.[s.key])) return true
|
|
if (s.validate) {
|
|
const errors = s.validate(currentValues?.[s.key])
|
|
return Object.keys(errors).length > 0
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
return result
|
|
})
|
|
|
|
$effect(() => {
|
|
hasAnyInvalid = Object.values(invalidCategories).some(Boolean)
|
|
})
|
|
|
|
export function isDirty(category: string): boolean {
|
|
return dirtyCategories[category] ?? false
|
|
}
|
|
|
|
export function discardCategory(category: string) {
|
|
const categorySettings = getSettingsForCategory(category)
|
|
for (const s of categorySettings) {
|
|
const v = initialValues[s.key]
|
|
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
|
}
|
|
if (category === 'Auth/OAuth/SAML') {
|
|
oauths = JSON.parse(JSON.stringify(initialOauths))
|
|
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
|
|
loadInstanceInputs(initialOauths)
|
|
} else if (category === 'Registries') {
|
|
const v = initialValues['workspace_registries']
|
|
$values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
|
}
|
|
}
|
|
|
|
export function discardAll() {
|
|
// Reset all values to initial state (deep copy to avoid reference sharing)
|
|
$values = JSON.parse(JSON.stringify(initialValues))
|
|
oauths = JSON.parse(JSON.stringify(initialOauths))
|
|
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
|
|
loadInstanceInputs(initialOauths)
|
|
if (yamlMode) {
|
|
syncFormToYaml()
|
|
}
|
|
}
|
|
|
|
export async function saveCategorySettings(category: string) {
|
|
// Category-specific pre-processing
|
|
if (category === 'Auth/OAuth/SAML') {
|
|
setupTemplatedOauthUrls()
|
|
}
|
|
|
|
if (category === 'Alerts' && $values?.critical_error_channels) {
|
|
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
|
|
if (!entry || typeof entry !== 'object') return false
|
|
if ('teams_channel' in entry) return isValidTeamsChannel(entry.teams_channel)
|
|
if ('slack_channel' in entry)
|
|
return typeof entry.slack_channel === 'string' && entry.slack_channel.trim() !== ''
|
|
if ('email' in entry) return typeof entry.email === 'string' && entry.email.trim() !== ''
|
|
return false
|
|
})
|
|
}
|
|
|
|
if (
|
|
category === 'Core' &&
|
|
$values?.['license_key'] &&
|
|
typeof $values['license_key'] === 'string'
|
|
) {
|
|
$values['license_key'] = $values['license_key'].trim()
|
|
}
|
|
|
|
let shouldReloadPage = false
|
|
let willRestart = false
|
|
const categorySettings = getSettingsForCategory(category)
|
|
|
|
let licenseKeySet = false
|
|
await Promise.all(
|
|
categorySettings
|
|
.filter((x) => {
|
|
return (
|
|
x.storage === 'setting' &&
|
|
!deepEqual(initialValues?.[x.key], $values?.[x.key]) &&
|
|
($values?.[x.key] !== '' ||
|
|
initialValues?.[x.key] !== undefined ||
|
|
initialValues?.[x.key] !== null)
|
|
)
|
|
})
|
|
.map(async (x) => {
|
|
if (x.key === 'license_key') licenseKeySet = true
|
|
if (x.requiresReloadOnChange) shouldReloadPage = true
|
|
if (x.triggersRestart) willRestart = true
|
|
let value = $values?.[x.key]
|
|
if (x.fieldType === 'codearea' && typeof value === 'string' && value.trim() === '') {
|
|
value = undefined
|
|
}
|
|
return await SettingService.setGlobal({
|
|
key: x.key,
|
|
requestBody: { value }
|
|
})
|
|
})
|
|
)
|
|
|
|
// Update only the saved category's initial values
|
|
for (const s of categorySettings) {
|
|
const v = $values[s.key]
|
|
initialValues[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
|
|
}
|
|
if (categorySettings.some((s) => s.key === 'base_url')) {
|
|
baseUrlIsFallback = false
|
|
}
|
|
|
|
// Handle Auth/OAuth/SAML-specific saves
|
|
if (category === 'Auth/OAuth/SAML') {
|
|
if (!deepEqual(stripEmpty(initialOauths), stripEmpty(oauths))) {
|
|
await SettingService.setGlobal({
|
|
key: 'oauths',
|
|
requestBody: { value: oauths }
|
|
})
|
|
initialOauths = JSON.parse(JSON.stringify(oauths))
|
|
}
|
|
if (initialRequirePreexistingUserForOauth !== requirePreexistingUserForOauth) {
|
|
await SettingService.setGlobal({
|
|
key: 'require_preexisting_user_for_oauth',
|
|
requestBody: { value: requirePreexistingUserForOauth }
|
|
})
|
|
initialRequirePreexistingUserForOauth = requirePreexistingUserForOauth
|
|
}
|
|
}
|
|
|
|
// Handle workspace_registries (saved separately like oauths)
|
|
if (category === 'Registries') {
|
|
if (!deepEqual(initialValues['workspace_registries'], $values['workspace_registries'])) {
|
|
await SettingService.setGlobal({
|
|
key: 'workspace_registries',
|
|
requestBody: { value: $values['workspace_registries'] ?? null }
|
|
})
|
|
initialValues['workspace_registries'] = $values['workspace_registries']
|
|
? JSON.parse(JSON.stringify($values['workspace_registries']))
|
|
: undefined
|
|
}
|
|
}
|
|
|
|
if (licenseKeySet) setLicense()
|
|
|
|
if (shouldReloadPage) {
|
|
sendUserToast('Settings updated, reloading page...')
|
|
await sleep(1000)
|
|
window.location.reload()
|
|
} else if (willRestart) {
|
|
sendUserToast(
|
|
'Settings updated. Servers are restarting and changes may take up to a minute to fully propagate.',
|
|
false,
|
|
[],
|
|
undefined,
|
|
8000
|
|
)
|
|
dispatch('saved')
|
|
} else {
|
|
sendUserToast('Settings updated')
|
|
dispatch('saved')
|
|
}
|
|
}
|
|
|
|
let yamlCode = $state('')
|
|
let yamlCodeInitial = $state('')
|
|
let yamlEditor: any | undefined = $state(undefined)
|
|
let yamlError = $state('')
|
|
let showSensitive = $state(false)
|
|
|
|
const SENSITIVE_UNCHANGED = '__SENSITIVE_AND_UNCHANGED__'
|
|
|
|
const sensitiveKeys: Set<string> = new Set([
|
|
...[...Object.values(settings), scimSamlSetting]
|
|
.flatMap((s) => Object.values(s))
|
|
.filter((s) => s.fieldType === 'password' || s.fieldType === 'license_key')
|
|
.map((s) => s.key),
|
|
'ducklake_user_pg_pwd',
|
|
'jwt_secret',
|
|
'workspace_registries'
|
|
])
|
|
|
|
// Settings that should never appear in YAML export/import.
|
|
// `worker_configs` is a legacy ghost key: worker configs live in the `config`
|
|
// table (managed from /workers), not in `global_settings`. Older DBs may
|
|
// still carry a stale `global_settings.worker_configs` row; filter it here
|
|
// so it never round-trips through this editor.
|
|
const excludedKeys: Set<string> = new Set(['worker_configs'])
|
|
|
|
// Nested fields inside object-valued settings that contain secrets.
|
|
// Each entry maps a top-level key to its sensitive sub-field names.
|
|
const nestedSensitiveFields: Record<string, string[]> = {
|
|
smtp_settings: ['smtp_password'],
|
|
secret_backend: ['token'],
|
|
object_store_cache_config: ['secret_key', 'serviceAccountKey'],
|
|
custom_instance_pg_databases: ['user_pwd'],
|
|
rsa_keys: ['private_key'],
|
|
github_enterprise_app: ['private_key']
|
|
}
|
|
|
|
/** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */
|
|
function maskField(current: any, initial: any): string | undefined {
|
|
if (current != null && current !== '' && current === initial) return SENSITIVE_UNCHANGED
|
|
return undefined
|
|
}
|
|
|
|
function maskSensitive(obj: Record<string, any>): Record<string, any> {
|
|
const masked: Record<string, any> = {}
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (key === 'oauths' && typeof value === 'object' && value !== null) {
|
|
const maskedOauths: Record<string, any> = {}
|
|
for (const [provider, config] of Object.entries(value as Record<string, any>)) {
|
|
if (typeof config === 'object' && config !== null && 'secret' in config) {
|
|
const m = maskField(config.secret, initialOauths?.[provider]?.secret)
|
|
maskedOauths[provider] = m ? { ...config, secret: m } : config
|
|
} else {
|
|
maskedOauths[provider] = config
|
|
}
|
|
}
|
|
masked[key] = maskedOauths
|
|
} else if (key in nestedSensitiveFields && typeof value === 'object' && value !== null) {
|
|
const cp = { ...value }
|
|
const init = initialValues?.[key]
|
|
for (const field of nestedSensitiveFields[key]) {
|
|
const m = maskField(
|
|
field === 'serviceAccountKey' ? JSON.stringify(cp[field]) : cp[field],
|
|
field === 'serviceAccountKey' ? JSON.stringify(init?.[field]) : init?.[field]
|
|
)
|
|
if (m) cp[field] = m
|
|
}
|
|
masked[key] = cp
|
|
} else if (sensitiveKeys.has(key) && value != null && value !== '') {
|
|
masked[key] = value === initialValues?.[key] ? SENSITIVE_UNCHANGED : value
|
|
} else {
|
|
masked[key] = value
|
|
}
|
|
}
|
|
return masked
|
|
}
|
|
|
|
/**
|
|
* Builds a sorted YAML string of all instance settings.
|
|
* - normalize: strip keys whose values match the default (empty/falsy or key-specific defaults)
|
|
* - mask: replace sensitive values with placeholder (for display)
|
|
*/
|
|
function buildSettingsYaml(
|
|
vals: Record<string, any>,
|
|
oauthsObj: Record<string, any>,
|
|
reqPreexisting: boolean,
|
|
opts: { normalize?: boolean; mask?: boolean } = {}
|
|
): string {
|
|
// Merge all settings (including oauths) into one object so they sort together
|
|
const merged: Record<string, any> = { ...vals }
|
|
if (oauthsObj && Object.keys(stripEmpty(oauthsObj)).length > 0) {
|
|
merged['oauths'] = oauthsObj
|
|
}
|
|
if (reqPreexisting) {
|
|
merged['require_preexisting_user_for_oauth'] = reqPreexisting
|
|
}
|
|
const obj: Record<string, any> = {}
|
|
for (const key of Object.keys(merged).sort()) {
|
|
if (excludedKeys.has(key)) continue
|
|
if (opts.normalize && normalizeValue(merged[key], key) === undefined) continue
|
|
obj[key] = merged[key]
|
|
}
|
|
// Strip runtime-only `databases` sub-field from custom_instance_pg_databases
|
|
if (obj['custom_instance_pg_databases']?.databases) {
|
|
obj['custom_instance_pg_databases'] = { ...obj['custom_instance_pg_databases'] }
|
|
delete obj['custom_instance_pg_databases'].databases
|
|
}
|
|
return YAML.stringify(opts.mask ? maskSensitive(obj) : obj)
|
|
}
|
|
|
|
function syncFormToYaml() {
|
|
yamlCode = buildSettingsYaml($values, oauths, requirePreexistingUserForOauth, {
|
|
normalize: true,
|
|
mask: !showSensitive
|
|
})
|
|
yamlCodeInitial = buildSettingsYaml(
|
|
initialValues,
|
|
initialOauths,
|
|
initialRequirePreexistingUserForOauth,
|
|
{ normalize: true, mask: !showSensitive }
|
|
)
|
|
yamlEditor?.setCode(yamlCode)
|
|
yamlError = ''
|
|
}
|
|
|
|
function syncYamlToForm(): boolean {
|
|
try {
|
|
// Flush the editor's current content (bypasses the 200ms debounce in SimpleEditor)
|
|
const currentCode = yamlEditor?.getCode() ?? yamlCode
|
|
if (currentCode !== yamlCode) {
|
|
yamlCode = currentCode
|
|
}
|
|
const parsed = YAML.parse(yamlCode)
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
sendUserToast('YAML must be a mapping (key: value)', true)
|
|
return false
|
|
}
|
|
|
|
// Restore sensitive values that were not changed (placeholder → original value)
|
|
if ('oauths' in parsed && typeof parsed['oauths'] === 'object') {
|
|
for (const [provider, config] of Object.entries(parsed['oauths'] as Record<string, any>)) {
|
|
if (
|
|
typeof config === 'object' &&
|
|
config !== null &&
|
|
config.secret === SENSITIVE_UNCHANGED
|
|
) {
|
|
config.secret = initialOauths?.[provider]?.secret
|
|
}
|
|
}
|
|
oauths = parsed['oauths'] ?? {}
|
|
delete parsed['oauths']
|
|
}
|
|
if ('require_preexisting_user_for_oauth' in parsed) {
|
|
requirePreexistingUserForOauth = parsed['require_preexisting_user_for_oauth'] ?? false
|
|
delete parsed['require_preexisting_user_for_oauth']
|
|
}
|
|
|
|
// Restore unchanged sensitive settings (placeholder → original value)
|
|
for (const key of sensitiveKeys) {
|
|
if (key in parsed && parsed[key] === SENSITIVE_UNCHANGED) {
|
|
parsed[key] = initialValues?.[key]
|
|
}
|
|
}
|
|
// Restore nested sensitive fields
|
|
for (const [parentKey, fields] of Object.entries(nestedSensitiveFields)) {
|
|
if (parsed[parentKey] && typeof parsed[parentKey] === 'object') {
|
|
const init = initialValues?.[parentKey]
|
|
for (const field of fields) {
|
|
if (parsed[parentKey][field] === SENSITIVE_UNCHANGED) {
|
|
parsed[parentKey][field] = init?.[field]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preserve excluded keys from current form state
|
|
for (const key of excludedKeys) {
|
|
if (key in $values) {
|
|
parsed[key] = $values[key]
|
|
}
|
|
}
|
|
|
|
// Preserve runtime-only `databases` sub-field in custom_instance_pg_databases
|
|
const existingDatabases = initialValues?.['custom_instance_pg_databases']?.databases
|
|
if (existingDatabases && parsed['custom_instance_pg_databases']) {
|
|
parsed['custom_instance_pg_databases'].databases = existingDatabases
|
|
}
|
|
|
|
$values = parsed
|
|
applyFormDefaults($values)
|
|
|
|
yamlError = ''
|
|
return true
|
|
} catch (e) {
|
|
yamlError = String(e)
|
|
sendUserToast('Invalid YAML: ' + e, true)
|
|
return false
|
|
}
|
|
}
|
|
|
|
let prevYamlMode = false
|
|
let prevLoading = true
|
|
$effect(() => {
|
|
if (yamlMode && !prevYamlMode) {
|
|
syncFormToYaml()
|
|
} else if (!yamlMode && prevYamlMode) {
|
|
if (!syncYamlToForm()) {
|
|
// Reset toggle back to YAML on parse failure
|
|
yamlMode = true
|
|
}
|
|
} else if (yamlMode && prevLoading && !loading) {
|
|
// Settings just finished loading while in YAML mode
|
|
syncFormToYaml()
|
|
}
|
|
prevYamlMode = yamlMode
|
|
prevLoading = loading
|
|
})
|
|
|
|
function handleShowSensitiveToggle(checked: boolean) {
|
|
// Sync any in-progress edits back to form state before re-rendering
|
|
syncYamlToForm()
|
|
showSensitive = checked
|
|
syncFormToYaml()
|
|
}
|
|
|
|
/** Call before entering diff mode to sync YAML edits into form state */
|
|
export function syncBeforeDiff(): boolean {
|
|
if (yamlMode) {
|
|
return syncYamlToForm()
|
|
}
|
|
return true
|
|
}
|
|
|
|
export function buildFullDiff(): { original: string; modified: string } {
|
|
return {
|
|
original: buildSettingsYaml(
|
|
initialValues,
|
|
initialOauths,
|
|
initialRequirePreexistingUserForOauth,
|
|
{ normalize: true }
|
|
),
|
|
modified: buildSettingsYaml($values, oauths, requirePreexistingUserForOauth, {
|
|
normalize: true
|
|
})
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (yamlMode) {
|
|
// In YAML mode, compare editor content against snapshot taken on entry
|
|
hasUnsavedChanges = yamlCodeInitial !== '' && yamlCode !== yamlCodeInitial
|
|
} else {
|
|
// Reuse per-category dirty tracking instead of rebuilding full YAML
|
|
hasUnsavedChanges = Object.values(dirtyCategories).some(Boolean)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div class="pb-12">
|
|
{#if yamlMode}
|
|
<div class="flex flex-row justify-between">
|
|
<p class="text-2xs text-tertiary">
|
|
Use this YAML to manage instance settings as code.
|
|
<a
|
|
href="https://www.windmill.dev/docs/advanced/instance_settings#kubernetes-operator"
|
|
target="_blank"
|
|
rel="noopener noreferrer">Learn more <ExternalLink size={12} class="inline-block" /></a
|
|
>
|
|
</p>
|
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
<div class="flex items-center justify-end gap-4 mb-2">
|
|
<Toggle
|
|
checked={showSensitive}
|
|
on:change={(e) => handleShowSensitiveToggle(e.detail)}
|
|
options={{ right: 'Show sensitive values' }}
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="border rounded w-full h-[calc(100vh-12rem)]">
|
|
{#await import('$lib/components/SimpleEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:this={yamlEditor}
|
|
class="h-full"
|
|
lang="yaml"
|
|
bind:code={yamlCode}
|
|
fixedOverflowWidgets={false}
|
|
/>
|
|
{/await}
|
|
</div>
|
|
{#if yamlError}
|
|
<div class="text-red-500 text-xs mt-1">{yamlError}</div>
|
|
{/if}
|
|
{:else if hideTabs}
|
|
{@render categoryContent(tab)}
|
|
{:else}
|
|
<Tabs bind:selected={tab}>
|
|
{#each settingsKeys as category}
|
|
<Tab value={category} label={category}></Tab>
|
|
{/each}
|
|
|
|
{#snippet content()}
|
|
<div class="pt-4"></div>
|
|
{#each Object.keys(settings) as category}
|
|
<TabContent value={category}>
|
|
{@render categoryContent(category)}
|
|
</TabContent>
|
|
{/each}
|
|
{/snippet}
|
|
</Tabs>
|
|
{/if}
|
|
|
|
{#snippet categoryContent(category: string)}
|
|
{#if category == 'Core'}
|
|
<SettingsPageHeader
|
|
title="Core"
|
|
description="Configure the core settings of your Windmill instance."
|
|
link="https://www.windmill.dev/docs/advanced/instance_settings"
|
|
/>
|
|
{:else if category == 'SMTP'}
|
|
<SettingsPageHeader
|
|
title="SMTP"
|
|
description="Setting SMTP unlocks sending emails upon adding new users to the workspace or the instance or sending critical alerts via email."
|
|
link="https://www.windmill.dev/docs/advanced/instance_settings#smtp"
|
|
/>
|
|
{:else if category == 'Registries'}
|
|
<SettingsPageHeader
|
|
title="Registries"
|
|
description="Add private registries for Pip, Bun and npm."
|
|
link="https://www.windmill.dev/docs/advanced/imports"
|
|
/>
|
|
{#if !$enterpriseLicense}
|
|
<Alert type="info" title="Private registries configuration is an EE feature" class="mb-2" />
|
|
{/if}
|
|
{:else if category == 'Alerts'}
|
|
<SettingsPageHeader
|
|
title="Alerts"
|
|
description="Critical alerts automatically notify administrators about system events like job crashes, license issues, worker failures, and queue delays through email, Slack, or Teams."
|
|
link="https://www.windmill.dev/docs/core_concepts/critical_alerts"
|
|
/>
|
|
{:else if category == 'OTEL/Prom'}
|
|
<SettingsPageHeader
|
|
title="OTEL/Prometheus"
|
|
description="Configure OpenTelemetry and Prometheus metrics export for monitoring your Windmill instance."
|
|
link="https://www.windmill.dev/docs/misc/guides/otel"
|
|
/>
|
|
{:else if category == 'Indexer'}
|
|
<SettingsPageHeader
|
|
title="Indexer"
|
|
description="The indexer service unlocks full text search across jobs and service logs. It requires spinning up its own separate container."
|
|
link="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
|
|
/>
|
|
{#if !$enterpriseLicense}
|
|
<Alert
|
|
type="info"
|
|
title="Full text search across jobs and service logs is an EE feature"
|
|
class="mb-2"
|
|
/>
|
|
{/if}
|
|
{:else if category == 'Telemetry'}
|
|
<SettingsPageHeader title="Telemetry" />
|
|
{#if $enterpriseLicense}
|
|
<div class="text-primary pb-4 text-xs">
|
|
Telemetry is required on Enterprise Edition for license compliance. When minimal telemetry
|
|
is enabled, only the following data is sent:
|
|
<ul class="list-disc list-inside pl-2">
|
|
<li>version of your instance</li>
|
|
<li>instance base URL</li>
|
|
<li>login type usage (login type, count)</li>
|
|
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
|
<li>user usage (author count, operator count)</li>
|
|
<li>superadmin email addresses</li>
|
|
<li>development instance status</li>
|
|
</ul>
|
|
<br />When minimal telemetry is disabled, the following is also collected:
|
|
<ul class="list-disc list-inside pl-2">
|
|
<li>job usage (language, total duration, count)</li>
|
|
<li>git sync repo count (sync vs promotion mode)</li>
|
|
<li
|
|
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
|
|
>
|
|
<li
|
|
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
|
apps, raw apps)</li
|
|
>
|
|
<li
|
|
>infrastructure info (container runtime, managed database provider, database version,
|
|
size and cluster size, max and active connections, object storage backend)</li
|
|
>
|
|
</ul>
|
|
<br />For air-gapped instances, you can download the telemetry data and send it manually.
|
|
</div>
|
|
<div class="flex gap-2 mb-4">
|
|
<Button
|
|
on:click={sendStats}
|
|
variant="default"
|
|
btnClasses="w-auto"
|
|
loading={sendingStats}
|
|
size="xs"
|
|
>
|
|
Send usage
|
|
</Button>
|
|
<Button
|
|
on:click={downloadStats}
|
|
variant="default"
|
|
btnClasses="w-auto"
|
|
loading={downloadingStats}
|
|
size="xs"
|
|
>
|
|
Download usage
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<div class="text-primary pb-4 text-xs">
|
|
Anonymous usage data is collected to help improve Windmill.
|
|
<br />The following information is collected:
|
|
<ul class="list-disc list-inside pl-2">
|
|
<li>version of your instance</li>
|
|
<li>instance base URL</li>
|
|
<li>job usage (language, total duration, count)</li>
|
|
<li>login type usage (login type, count)</li>
|
|
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
|
<li>user usage (author count, operator count)</li>
|
|
<li>development instance status</li>
|
|
<li
|
|
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
|
|
>
|
|
<li
|
|
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
|
apps, raw apps)</li
|
|
>
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
{:else if category == 'Jobs'}
|
|
<SettingsPageHeader
|
|
title="Jobs"
|
|
description="Configure default timeouts and retention policies for job execution."
|
|
link="https://www.windmill.dev/docs/advanced/instance_settings#jobs"
|
|
/>
|
|
{:else if category == 'Object Storage'}
|
|
<SettingsPageHeader
|
|
title="Object Storage"
|
|
description="Configure S3-compatible storage for large logs and distributed dependency caching."
|
|
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill"
|
|
/>
|
|
{:else if category == 'Private Hub'}
|
|
<SettingsPageHeader
|
|
title="Private Hub"
|
|
description="Connect to a Private Hub instance for sharing custom scripts and integrations."
|
|
link="https://www.windmill.dev/docs/core_concepts/private_hub"
|
|
/>
|
|
{:else if category == 'Secret Storage'}
|
|
<SettingsPageHeader
|
|
title="Secret Storage"
|
|
description="Configure where secrets (secret variables) are stored."
|
|
link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption"
|
|
/>
|
|
{:else if category == 'GitHub Enterprise App'}
|
|
<SettingsPageHeader
|
|
title="GitHub Enterprise App"
|
|
description="Configure a self-managed GitHub App for GitHub Enterprise Server git sync."
|
|
/>
|
|
{:else if category == 'DB Health'}
|
|
<SettingsPageHeader
|
|
title="DB Health"
|
|
description="On-demand database diagnostics. Analyze table sizes, job retention, connection pool health, vacuum status, and more."
|
|
/>
|
|
<DbHealth />
|
|
{:else if category == 'Auth/OAuth/SAML'}
|
|
<AuthSettings
|
|
bind:oauths
|
|
bind:instanceInputs
|
|
bind:requirePreexistingUserForOauth
|
|
baseUrl={$values?.base_url}
|
|
bind:tab={authSubTab}
|
|
{hideTabs}
|
|
>
|
|
{#snippet scim()}
|
|
<div class="flex-col flex gap-6 pb-4">
|
|
{#each scimSamlSetting as setting}
|
|
<InstanceSetting
|
|
on:closeDrawer={() => closeDrawer?.()}
|
|
{loading}
|
|
{setting}
|
|
{values}
|
|
{version}
|
|
{oauths}
|
|
/>
|
|
{#if setting.key === 'scim_token'}
|
|
<ScimOauthSettings />
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
</AuthSettings>
|
|
{/if}
|
|
|
|
<div class="flex-col flex gap-6 pb-6">
|
|
{#each settings[category] as setting}
|
|
<!-- slack connect is handled with the alert channels settings, smtp_connect is handled in InstanceSetting -->
|
|
{#if setting.fieldType != 'slack_connect' && !(quickSetup && setting.hideInQuickSetup) && !(quickSetup && category === 'Core' && setting.key === 'license_key')}
|
|
<InstanceSetting
|
|
{openSmtpSettings}
|
|
on:closeDrawer={() => closeDrawer?.()}
|
|
{loading}
|
|
{setting}
|
|
{values}
|
|
{version}
|
|
{oauths}
|
|
warning={setting.key === 'base_url' && baseUrlIsFallback
|
|
? 'Auto-detected from browser — not yet saved'
|
|
: setting.key === 'nuget_config' &&
|
|
$values['nuget_config'] &&
|
|
!/<clear\s*\/>/.test($values['nuget_config'])
|
|
? 'Missing <clear /> in <packageSources>. Without it, default sources (like nuget.org) are merged with your custom sources, which is likely not what you want.'
|
|
: undefined}
|
|
/>
|
|
{/if}
|
|
{#if quickSetup && category === 'Core' && setting.key === 'base_url'}
|
|
{@const licenseKeySetting = settings['Core'].find((s) => s.key === 'license_key')}
|
|
{#if licenseKeySetting}
|
|
<InstanceSetting
|
|
{openSmtpSettings}
|
|
on:closeDrawer={() => closeDrawer?.()}
|
|
{loading}
|
|
setting={licenseKeySetting}
|
|
{values}
|
|
{version}
|
|
{oauths}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
{#if quickSetup && category === 'Core'}
|
|
{@const extraSettings = [
|
|
...settings['Jobs'].filter((s) => s.key === 'job_isolation'),
|
|
...settings['Jobs'].filter((s) => s.key === 'retention_period_secs'),
|
|
...(settings['Object Storage']?.filter((s) => s.key === 'object_store_cache_config') ??
|
|
[])
|
|
]}
|
|
{#each extraSettings as setting}
|
|
<InstanceSetting
|
|
{openSmtpSettings}
|
|
on:closeDrawer={() => closeDrawer?.()}
|
|
{loading}
|
|
{setting}
|
|
{values}
|
|
{version}
|
|
{oauths}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if category === 'Registries'}
|
|
<WorkspaceRegistries {values} {loading} />
|
|
{/if}
|
|
|
|
{#if !loading && !quickSetup && !hideTabs}
|
|
<SettingsFooter
|
|
hasUnsavedChanges={dirtyCategories[category] ?? false}
|
|
disabled={invalidCategories[category] ?? false}
|
|
onSave={() => saveCategorySettings(category)}
|
|
onDiscard={() => discardCategory(category)}
|
|
saveLabel={`Save ${category.toLowerCase()} settings`}
|
|
class="bg-surface"
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
</div>
|