@jeffusion/bungee-core 5.26.0 → 6.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config-storage-worker.js +455 -223
- package/dist/main.js +803 -437
- package/dist/observability-worker.js +0 -10
- package/dist/plugins/anthropic-request-sanitizer/index.js.map +2 -2
- package/dist/plugins/anthropic-request-sanitizer/manifest.json +1 -1
- package/dist/plugins/anthropic-tool-name-transformer/index.js.map +2 -2
- package/dist/plugins/anthropic-tool-name-transformer/manifest.json +1 -1
- package/dist/plugins/chatgpt-oauth/control.js.map +1 -1
- package/dist/plugins/chatgpt-oauth/index.js.map +2 -2
- package/dist/plugins/chatgpt-oauth/manifest.json +1 -1
- package/dist/plugins/codex-router/control.js.map +1 -1
- package/dist/plugins/codex-router/index.js +2 -2
- package/dist/plugins/codex-router/index.js.map +3 -3
- package/dist/plugins/codex-router/manifest.json +1 -1
- package/dist/plugins/deepseek-reasoning-fix/index.js.map +2 -2
- package/dist/plugins/deepseek-reasoning-fix/manifest.json +1 -1
- package/dist/plugins/key-access/control.js.map +1 -1
- package/dist/plugins/key-access/index.js.map +2 -2
- package/dist/plugins/key-access/ingress.js.map +1 -1
- package/dist/plugins/key-access/manifest.json +1 -1
- package/dist/plugins/key-rate-limit/index.js.map +2 -2
- package/dist/plugins/key-rate-limit/manifest.json +1 -1
- package/dist/plugins/llm-protocol-adapter/control.js +2 -2
- package/dist/plugins/llm-protocol-adapter/control.js.map +4 -4
- package/dist/plugins/llm-protocol-adapter/index.js +2 -2
- package/dist/plugins/llm-protocol-adapter/index.js.map +3 -3
- package/dist/plugins/llm-protocol-adapter/manifest.json +1 -1
- package/dist/plugins/model-mapping/index.js.map +2 -2
- package/dist/plugins/model-mapping/manifest.json +1 -1
- package/dist/plugins/models-dev/index.js.map +2 -2
- package/dist/plugins/models-dev/manifest.json +1 -1
- package/dist/plugins/signature-repair/index.js.map +2 -2
- package/dist/plugins/signature-repair/manifest.json +1 -1
- package/dist/plugins/token-budget/control.js.map +1 -1
- package/dist/plugins/token-budget/index.js.map +2 -2
- package/dist/plugins/token-budget/manifest.json +1 -1
- package/dist/plugins/token-metering/index.js.map +2 -2
- package/dist/plugins/token-metering/manifest.json +1 -1
- package/dist/plugins/token-stats/control.js.map +1 -1
- package/dist/plugins/token-stats/index.js.map +2 -2
- package/dist/plugins/token-stats/manifest.json +1 -1
- package/dist/plugins/user-auth/control.js +3911 -0
- package/dist/plugins/{local-accounts → user-auth}/control.js.map +8 -5
- package/dist/plugins/{local-accounts → user-auth}/index.js +6 -6
- package/dist/plugins/{local-accounts → user-auth}/index.js.map +6 -6
- package/dist/plugins/user-auth/manifest.json +735 -0
- package/dist/plugins/user-auth/ui/ConnectionEditor.svelte +137 -0
- package/dist/plugins/user-auth/ui/Login.svelte +87 -0
- package/dist/plugins/user-auth/ui/LoginProviderIcon.svelte +19 -0
- package/dist/plugins/user-auth/ui/LoginSettings.svelte +141 -0
- package/dist/plugins/user-auth/ui/Profile.svelte +185 -0
- package/dist/plugins/{local-accounts → user-auth}/ui/RecoveryHelp.svelte +3 -3
- package/dist/plugins/user-auth/ui/SessionPolicy.svelte +104 -0
- package/dist/plugins/user-auth/ui/Settings.svelte +14 -0
- package/dist/plugins/user-auth/ui/SettingsSection.svelte +32 -0
- package/dist/plugins/user-auth/ui/Setup.svelte +38 -0
- package/dist/plugins/user-auth/ui/TurnstileChallenge.svelte +26 -0
- package/dist/plugins/user-auth/ui/TurnstileSettings.svelte +81 -0
- package/dist/plugins/user-auth/ui/Users.svelte +95 -0
- package/dist/plugins/user-auth/ui/assets/LICENSE.txt +30 -0
- package/dist/plugins/user-auth/ui/assets/github.svg +1 -0
- package/dist/plugins/user-auth/ui/assets/google.svg +1 -0
- package/dist/plugins/user-auth/ui/assets/microsoft.svg +1 -0
- package/dist/plugins/user-auth/ui/contracts.ts +16 -0
- package/dist/plugins/{local-accounts → user-auth}/ui/login-errors.ts +4 -1
- package/dist/plugins/user-auth/ui/turnstile.ts +21 -0
- package/package.json +2 -2
- package/dist/plugins/local-accounts/control.js +0 -715
- package/dist/plugins/local-accounts/manifest.json +0 -223
- package/dist/plugins/local-accounts/ui/Login.svelte +0 -47
- package/dist/plugins/local-accounts/ui/Settings.svelte +0 -117
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, onMount, untrack } from 'svelte';
|
|
3
|
+
import { _, isLoading } from 'svelte-i18n';
|
|
4
|
+
import { requestPluginControl } from '$api/client';
|
|
5
|
+
import { IndustrialDialog, IndustrialToggle } from '$components/industrial';
|
|
6
|
+
import { Button } from '$components/ui/button';
|
|
7
|
+
import type { Connection, ConnectionInput, DiscoveryMetadata, LoginProvider, MicrosoftConfiguration, OidcConfiguration } from './contracts';
|
|
8
|
+
|
|
9
|
+
let {open = $bindable(), pluginName, connection, connectionId, publicOrigin, busy, error, onsave}: {
|
|
10
|
+
open: boolean; pluginName: string; connection: Connection | null; connectionId: string; publicOrigin?: string;
|
|
11
|
+
busy: boolean; error: string; onsave: (input: ConnectionInput) => Promise<void>;
|
|
12
|
+
} = $props();
|
|
13
|
+
const id = $props.id();
|
|
14
|
+
const t = (key: string) => $isLoading ? '' : $_(`plugins.user-auth.${key}`);
|
|
15
|
+
const existing = untrack(() => connection);
|
|
16
|
+
const initial = existing?.configuration;
|
|
17
|
+
const custom = existing?.provider === 'oidc' ? initial as OidcConfiguration : undefined;
|
|
18
|
+
const microsoft = existing?.provider === 'microsoft' ? initial as MicrosoftConfiguration : undefined;
|
|
19
|
+
const providers: LoginProvider[] = ['google', 'microsoft', 'github', 'oidc'];
|
|
20
|
+
const guides: Record<LoginProvider, string> = {
|
|
21
|
+
google: 'https://console.cloud.google.com/auth/clients',
|
|
22
|
+
microsoft: 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
|
|
23
|
+
github: 'https://github.com/settings/applications/new',
|
|
24
|
+
oidc: 'https://openid.net/developers/how-connect-works/',
|
|
25
|
+
};
|
|
26
|
+
let provider = $state<LoginProvider | ''>(existing?.provider ?? '');
|
|
27
|
+
let label = $state(existing?.label ?? ''), enabled = $state(existing?.enabled ?? false);
|
|
28
|
+
let clientId = $state(existing?.clientId ?? ''), clientSecret = $state('');
|
|
29
|
+
let accountType = $state<MicrosoftConfiguration['accountType']>(microsoft?.accountType ?? 'tenant'), tenantId = $state(microsoft?.tenantId ?? '');
|
|
30
|
+
let mode = $state<OidcConfiguration['mode']>(custom?.mode ?? 'issuer');
|
|
31
|
+
let source = $state(custom?.mode === 'discovery' ? custom.discoveryUrl ?? '' : custom?.issuer ?? '');
|
|
32
|
+
let manualIssuer = $state(custom?.issuer ?? ''), authorizationEndpoint = $state(custom?.authorizationEndpoint ?? '');
|
|
33
|
+
let tokenEndpoint = $state(custom?.tokenEndpoint ?? ''), jwksUri = $state(custom?.jwksUri ?? '');
|
|
34
|
+
let tokenEndpointAuthMethod = $state<'client_secret_basic' | 'client_secret_post'>(custom?.tokenEndpointAuthMethod ?? 'client_secret_basic');
|
|
35
|
+
let idTokenSigningAlg = $state<'RS256' | 'PS256' | 'ES256'>(custom?.idTokenSigningAlg ?? 'RS256');
|
|
36
|
+
let scopes = $state(existing?.scopes.join(' ') ?? 'openid profile email');
|
|
37
|
+
let metadata = $state<DiscoveryMetadata | null>(null), metadataKey = $state(''), discovering = $state(false), discoveryError = $state(''), localError = $state(''), copied = $state(false);
|
|
38
|
+
let activeRequest: AbortController | undefined, requestVersion = 0, destroyed = false;
|
|
39
|
+
const callback = $derived(publicOrigin ? `${publicOrigin.replace(/\/$/, '')}/api/auth/login/callback/${encodeURIComponent(connectionId)}` : '');
|
|
40
|
+
const currentKey = $derived(`${provider}:${mode}:${source.trim()}`);
|
|
41
|
+
const metadataCurrent = $derived(metadata !== null && metadataKey === currentKey);
|
|
42
|
+
const urlFields = $derived([manualIssuer, authorizationEndpoint, tokenEndpoint, jwksUri]);
|
|
43
|
+
const configurationValid = $derived(provider === 'microsoft'
|
|
44
|
+
? accountType !== 'tenant' || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tenantId.trim())
|
|
45
|
+
: provider !== 'oidc' || (mode === 'manual' ? urlFields.every(validUrl) : metadataCurrent));
|
|
46
|
+
const canSave = $derived(!!provider && !!label.trim() && !!clientId.trim() && (!!clientSecret || !!existing?.hasSecret) && configurationValid && (provider !== 'oidc' || scopes.split(/\s+/).includes('openid')) && (!enabled || !!publicOrigin));
|
|
47
|
+
|
|
48
|
+
function validUrl(value: string): boolean {
|
|
49
|
+
try { const url = new URL(value); return !url.username && !url.password && !url.hash && (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))); }
|
|
50
|
+
catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
function cancelDiscovery() { requestVersion++; activeRequest?.abort(); activeRequest = undefined; discovering = false; metadata = null; metadataKey = ''; discoveryError = ''; }
|
|
53
|
+
function choose(value: LoginProvider) { if (existing || busy) return; cancelDiscovery(); provider = value; localError = ''; }
|
|
54
|
+
function changeMode(value: string) { cancelDiscovery(); mode = value as OidcConfiguration['mode']; source = ''; localError = ''; }
|
|
55
|
+
function invalidateSource() { cancelDiscovery(); localError = ''; }
|
|
56
|
+
async function discover() {
|
|
57
|
+
if (!open || provider !== 'oidc' || mode === 'manual' || busy) return;
|
|
58
|
+
const url = source.trim();
|
|
59
|
+
if (!validUrl(url)) { cancelDiscovery(); if (url) discoveryError = t('connections.invalidUrl'); return; }
|
|
60
|
+
if (metadataCurrent || discovering) return;
|
|
61
|
+
cancelDiscovery();
|
|
62
|
+
const version = requestVersion, key = currentKey, controller = new AbortController(); activeRequest = controller; discovering = true;
|
|
63
|
+
try {
|
|
64
|
+
const result = await requestPluginControl<DiscoveryMetadata>(pluginName, '/login-connections/discover', 'POST', {mode, url}, controller.signal);
|
|
65
|
+
if (destroyed || !open || controller.signal.aborted || version !== requestVersion || key !== currentKey) return;
|
|
66
|
+
if (![result.issuer, result.authorizationEndpoint, result.tokenEndpoint, result.jwksUri, result.discoveryUrl].every(validUrl)) throw new Error('oidc_metadata_invalid');
|
|
67
|
+
metadata = result; metadataKey = key;
|
|
68
|
+
} catch { if (!destroyed && open && !controller.signal.aborted && version === requestVersion && key === currentKey) discoveryError = t('connections.discoveryFailed'); }
|
|
69
|
+
finally { if (version === requestVersion) { discovering = false; activeRequest = undefined; } }
|
|
70
|
+
}
|
|
71
|
+
async function copyCallback() {
|
|
72
|
+
try { await navigator.clipboard.writeText(callback); copied = true; }
|
|
73
|
+
catch { localError = t('connections.copyFailed'); }
|
|
74
|
+
}
|
|
75
|
+
async function save(event: SubmitEvent) {
|
|
76
|
+
event.preventDefault(); if (busy || !canSave || !provider) return;
|
|
77
|
+
let configuration: Connection['configuration'] = {};
|
|
78
|
+
if (provider === 'microsoft') configuration = {accountType, ...(accountType === 'tenant' ? {tenantId: tenantId.trim()} : {})};
|
|
79
|
+
if (provider === 'oidc') {
|
|
80
|
+
if (mode === 'manual') configuration = {mode, issuer: manualIssuer.trim(), authorizationEndpoint: authorizationEndpoint.trim(), tokenEndpoint: tokenEndpoint.trim(), jwksUri: jwksUri.trim(), tokenEndpointAuthMethod, idTokenSigningAlg};
|
|
81
|
+
else if (metadataCurrent && metadata) configuration = mode === 'issuer' ? {mode, issuer: source.trim()} : {mode, issuer: metadata.issuer, discoveryUrl: source.trim()};
|
|
82
|
+
else return;
|
|
83
|
+
}
|
|
84
|
+
await onsave({id: connectionId, label: label.trim(), enabled, provider, clientId: clientId.trim(), configuration,
|
|
85
|
+
...(existing ? {version: existing.version} : {}), ...(clientSecret ? {clientSecret} : {}), ...(provider === 'oidc' ? {scopes: scopes.split(/\s+/).filter(Boolean)} : {})});
|
|
86
|
+
}
|
|
87
|
+
onMount(() => { if (existing?.provider === 'oidc' && mode !== 'manual') void discover(); });
|
|
88
|
+
onDestroy(() => { destroyed = true; cancelDiscovery(); clientSecret = ''; });
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
{#snippet body()}
|
|
92
|
+
<form id={id + '-connection'} class="space-y-4" onsubmit={save} aria-label={t('connections.editor')}>
|
|
93
|
+
<div class="space-y-2">
|
|
94
|
+
<p class="nx-field-label" id={id + '-provider-label'}>{t('connections.provider')}</p>
|
|
95
|
+
{#if existing}<p class="text-sm text-zinc-200">{t('providers.' + existing.provider)}</p><p class="text-sm text-zinc-400">{t('connections.providerFixed')}</p>
|
|
96
|
+
{:else}<div class="grid grid-cols-2 gap-2" role="group" aria-labelledby={id + '-provider-label'}>{#each providers as value}<Button type="button" variant={provider === value ? 'default' : 'outline'} disabled={busy} aria-pressed={provider === value} onclick={() => choose(value)}>{t('providers.' + value)}</Button>{/each}</div>{/if}
|
|
97
|
+
</div>
|
|
98
|
+
<div class="space-y-2">
|
|
99
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.callback')}</span><input class="nx-input font-mono" readonly value={callback} data-testid="login-callback" /></label>
|
|
100
|
+
{#if callback}<Button type="button" variant="ghost" onclick={copyCallback}>{t(copied ? 'connections.copied' : 'connections.copy')}</Button>{:else}<p role="alert" class="text-sm text-red-400">{t('connections.missingOrigin')}</p>{/if}
|
|
101
|
+
</div>
|
|
102
|
+
{#if provider}
|
|
103
|
+
<div class="space-y-1"><a class="text-sm text-nexus-300 underline" href={guides[provider]} target="_blank" rel="noreferrer">{t(provider === 'oidc' ? 'connections.oidcGuide' : 'connections.createApp')}</a><p class="text-sm text-zinc-400">{t('guides.' + provider)}</p></div>
|
|
104
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.label')}</span><input class="nx-input" required bind:value={label} disabled={busy} /></label>
|
|
105
|
+
{#if provider === 'microsoft'}
|
|
106
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.accountType')}</span><select class="nx-input" bind:value={accountType} disabled={busy}>{#each ['tenant', 'organizations', 'consumers', 'common'] as value}<option value={value}>{t('accounts.' + value)}</option>{/each}</select></label>
|
|
107
|
+
{#if accountType === 'tenant'}<label class="grid gap-1"><span class="nx-field-label">Tenant ID</span><input class="nx-input" required bind:value={tenantId} disabled={busy} placeholder="00000000-0000-0000-0000-000000000000" pattern={'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'} /></label>{/if}
|
|
108
|
+
{/if}
|
|
109
|
+
{#if provider === 'oidc'}
|
|
110
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.mode')}</span><select class="nx-input" value={mode} onchange={event => changeMode(event.currentTarget.value)} disabled={busy}><option value="issuer">{t('connections.modeIssuer')}</option><option value="discovery">{t('connections.modeDiscovery')}</option><option value="manual">{t('connections.modeManual')}</option></select></label>
|
|
111
|
+
{#if mode !== 'manual'}
|
|
112
|
+
<label class="grid gap-1"><span class="nx-field-label">{t(mode === 'issuer' ? 'connections.issuer' : 'connections.discoveryUrl')}</span><input class="nx-input" required type="url" bind:value={source} oninput={invalidateSource} onblur={discover} disabled={busy} /></label>
|
|
113
|
+
{#if discovering}<p class="text-sm text-zinc-400" role="status">{t('connections.discovering')}</p>{/if}
|
|
114
|
+
{#if discoveryError}<p role="alert" class="text-sm text-red-400">{discoveryError}</p>{/if}
|
|
115
|
+
{#if metadataCurrent && metadata}<dl class="space-y-2" data-testid="discovered-metadata">{#each [['connections.issuer', metadata.issuer], ['connections.authorizationEndpoint', metadata.authorizationEndpoint], ['connections.tokenEndpoint', metadata.tokenEndpoint], ['connections.jwksUri', metadata.jwksUri]] as [key, value]}<div><dt class="nx-field-label">{t(key)}</dt><dd class="break-all text-sm text-zinc-200">{value}</dd></div>{/each}</dl>{/if}
|
|
116
|
+
{:else}
|
|
117
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.issuer')}</span><input class="nx-input" required type="url" bind:value={manualIssuer} disabled={busy} /></label>
|
|
118
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.authorizationEndpoint')}</span><input class="nx-input" required type="url" bind:value={authorizationEndpoint} disabled={busy} /></label>
|
|
119
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.tokenEndpoint')}</span><input class="nx-input" required type="url" bind:value={tokenEndpoint} disabled={busy} /></label>
|
|
120
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.jwksUri')}</span><input class="nx-input" required type="url" bind:value={jwksUri} disabled={busy} /></label>
|
|
121
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.authMethod')}</span><select class="nx-input" bind:value={tokenEndpointAuthMethod} disabled={busy}><option value="client_secret_basic">HTTP Basic</option><option value="client_secret_post">POST</option></select></label>
|
|
122
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.signingAlg')}</span><select class="nx-input" bind:value={idTokenSigningAlg} disabled={busy}>{#each ['RS256', 'PS256', 'ES256'] as value}<option value={value}>{value}</option>{/each}</select></label>
|
|
123
|
+
{/if}
|
|
124
|
+
{/if}
|
|
125
|
+
{#if provider !== 'oidc' || mode === 'manual' || metadataCurrent}
|
|
126
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.clientId')}</span><input class="nx-input" required bind:value={clientId} disabled={busy} /></label>
|
|
127
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('connections.secret')}</span><input class="nx-input" type="password" autocomplete="new-password" required={!existing?.hasSecret} bind:value={clientSecret} disabled={busy} placeholder={existing?.hasSecret ? t('admin.secretRetained') : ''} /></label>
|
|
128
|
+
{/if}
|
|
129
|
+
{#if provider === 'oidc'}<details><summary class="cursor-pointer text-sm text-zinc-400">{t('connections.advanced')}</summary><label class="mt-3 grid gap-1"><span class="nx-field-label">{t('connections.scopes')}</span><input class="nx-input" required bind:value={scopes} disabled={busy} /></label></details>{/if}
|
|
130
|
+
{#if existing}<div class="flex items-center gap-2"><IndustrialToggle id={id + '-enabled'} bind:checked={enabled} disabled={busy} label={t('connections.enabled')} /><label for={id + '-enabled'} class="nx-field-label cursor-pointer">{t('connections.enabled')}</label></div>{/if}
|
|
131
|
+
<p class="text-sm text-zinc-400">{t('connections.saveNote')}</p>
|
|
132
|
+
{/if}
|
|
133
|
+
{#if error || localError}<p role="alert" class="text-sm text-red-400">{error || localError}</p>{/if}
|
|
134
|
+
</form>
|
|
135
|
+
{/snippet}
|
|
136
|
+
{#snippet footer()}<Button type="submit" form={id + '-connection'} disabled={busy || !canSave || discovering}>{t('admin.save')}</Button>{/snippet}
|
|
137
|
+
<IndustrialDialog bind:open title={t(existing ? 'connections.edit' : 'connections.new')} description={t('connections.description')} {busy} closeLabel={t('admin.close')} {body} {footer} scrollBody onOpenChange={value => {if (!value) {cancelDiscovery(); clientSecret = '';}}} />
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, onMount } from 'svelte';
|
|
3
|
+
import { isLoading } from 'svelte-i18n';
|
|
4
|
+
import { _, isManagementLoginStaleError, type ManagementLoginContext } from '@bungee/plugin-sdk';
|
|
5
|
+
import TurnstileChallenge from './TurnstileChallenge.svelte';
|
|
6
|
+
import RecoveryHelp from './RecoveryHelp.svelte';
|
|
7
|
+
import type { LoginProvider } from './contracts';
|
|
8
|
+
import { loginFailure, requireLoginSuccess } from './login-errors';
|
|
9
|
+
|
|
10
|
+
let { context }: { context: ManagementLoginContext } = $props();
|
|
11
|
+
const t = (key: string, values: Record<string, string> = {}) => $isLoading ? '' : $_(`plugins.user-auth.${key}`, { values });
|
|
12
|
+
let username = $state(''), password = $state(''), busy = $state(false);
|
|
13
|
+
let error = $state(''), detail = $state('');
|
|
14
|
+
type LoginOptions = {local: {enabled: true; turnstile: {enabled: boolean; siteKey?: string}}; methods: {id: string; label: string; provider: LoginProvider}[]};
|
|
15
|
+
let options = $state<LoginOptions | null>(null), optionsBusy = $state(true), token = $state(''), resetKey = $state(0);
|
|
16
|
+
let destroyed = false;
|
|
17
|
+
async function loadOptions() {
|
|
18
|
+
optionsBusy = true; error = ''; detail = '';
|
|
19
|
+
const activeContext = context;
|
|
20
|
+
try {
|
|
21
|
+
const result = await activeContext.loginOptions<LoginOptions>();
|
|
22
|
+
if (destroyed || context !== activeContext) return;
|
|
23
|
+
if (!result?.local || result.local.enabled !== true || typeof result.local.turnstile?.enabled !== 'boolean' || !Array.isArray(result.methods)
|
|
24
|
+
|| result.methods.some(item => !item || typeof item.id !== 'string' || !item.id || typeof item.label !== 'string' || !item.label || !['google', 'microsoft', 'github', 'oidc'].includes(item.provider))
|
|
25
|
+
|| (result.local.turnstile.enabled && !result.local.turnstile.siteKey)) throw new Error('provider_unavailable');
|
|
26
|
+
options = result;
|
|
27
|
+
} catch (cause) { if (!destroyed && context === activeContext && !isManagementLoginStaleError(cause)) { const failure = loginFailure(cause); error = failure.key; detail = failure.detail; } }
|
|
28
|
+
finally { if (!destroyed && context === activeContext) optionsBusy = false; }
|
|
29
|
+
}
|
|
30
|
+
onMount(() => {
|
|
31
|
+
void loadOptions().then(() => {
|
|
32
|
+
const code = new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('oidcError');
|
|
33
|
+
if (code && !destroyed) { const failure = loginFailure(new Error(code)); error = failure.key; detail = failure.key === 'login.errors.unknown' ? 'oidc_failed' : failure.detail; }
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
async function begin(methodId: string) {
|
|
37
|
+
if (busy) return; busy = true; error = ''; detail = '';
|
|
38
|
+
const activeContext = context;
|
|
39
|
+
try {
|
|
40
|
+
const result = await activeContext.beginLogin({methodId});
|
|
41
|
+
if (destroyed || context !== activeContext) return;
|
|
42
|
+
const url = new URL(result.authorizationUrl);
|
|
43
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('oidc_failed');
|
|
44
|
+
window.location.assign(url.href);
|
|
45
|
+
} catch (cause) { if (!destroyed && context === activeContext && !isManagementLoginStaleError(cause)) { const failure = loginFailure(cause); error = failure.key; detail = failure.detail; } }
|
|
46
|
+
finally { if (!destroyed && context === activeContext) busy = false; }
|
|
47
|
+
}
|
|
48
|
+
onDestroy(() => { destroyed = true; });
|
|
49
|
+
|
|
50
|
+
async function login(event: SubmitEvent) {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
if (busy) return;
|
|
53
|
+
if (!options || optionsBusy || (options.local.turnstile.enabled && !token)) return;
|
|
54
|
+
const activeContext = context;
|
|
55
|
+
busy = true; error = ''; detail = '';
|
|
56
|
+
try {
|
|
57
|
+
const result = await activeContext.login({ username, password, transport: 'cookie', ...(options.local.turnstile.enabled ? { turnstileToken: token } : {}) });
|
|
58
|
+
if (destroyed || context !== activeContext || result === undefined) return;
|
|
59
|
+
requireLoginSuccess(result);
|
|
60
|
+
await activeContext.complete();
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
if (destroyed || context !== activeContext || isManagementLoginStaleError(cause)) return;
|
|
63
|
+
const failure = loginFailure(cause);
|
|
64
|
+
error = failure.key; detail = failure.detail;
|
|
65
|
+
} finally {
|
|
66
|
+
password = ''; token = ''; resetKey++;
|
|
67
|
+
if (!destroyed && context === activeContext) busy = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<form onsubmit={login} aria-label={t('login.title')} aria-busy={busy} class="space-y-4">
|
|
73
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('login.username')}</span><input class="nx-input" bind:value={username} autocomplete="username" required maxlength="64" disabled={busy} /></label>
|
|
74
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('login.password')}</span><input class="nx-input" bind:value={password} type="password" autocomplete="current-password" required disabled={busy} /></label>
|
|
75
|
+
{#if error}
|
|
76
|
+
<p role="alert" class="text-sm text-red-400">{t(error)}</p>
|
|
77
|
+
{#if detail === 'invalid_origin' && context.provider.publicOrigin}<a class="text-sm text-nexus-300 underline break-all" href={`${context.provider.publicOrigin}/#/login`}>{t('login.correctAddress', { origin: context.provider.publicOrigin })}</a>{/if}
|
|
78
|
+
<details class="text-sm text-zinc-400"><summary>{t('login.technicalDetails')}</summary><pre class="whitespace-pre-wrap break-all">{detail}</pre></details>
|
|
79
|
+
{/if}
|
|
80
|
+
{#if options?.local.turnstile.enabled && options.local.turnstile.siteKey}<TurnstileChallenge siteKey={options.local.turnstile.siteKey} {resetKey} onToken={value => { token = value; }} retryLabel={t('login.retryChallenge')} failureLabel={t('login.challengeFailed')} />{/if}
|
|
81
|
+
{#if !options && !optionsBusy}<button type="button" class="nx-btn-ghost" onclick={loadOptions}>{t('session.reload')}</button>{/if}
|
|
82
|
+
<RecoveryHelp />
|
|
83
|
+
<button class="nx-btn-primary w-full justify-center" disabled={busy || optionsBusy || !options || (options.local.turnstile.enabled && !token)}>{busy ? t('login.busy') : t('login.submit')}</button>
|
|
84
|
+
</form>
|
|
85
|
+
{#if options?.methods.length}<div class="mt-4 grid gap-3" aria-label={t('login.methods')}>
|
|
86
|
+
{#each options.methods as connection (connection.id)}<button type="button" class="nx-btn-ghost w-full justify-center gap-2 break-words" aria-label={`${connection.label} · ${t('providers.' + connection.provider)}`} disabled={busy} onclick={() => begin(connection.id)}><span>{connection.label}</span><span class="text-sm text-zinc-400">{t('providers.' + connection.provider)}</span></button>{/each}
|
|
87
|
+
</div>{/if}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Globe } from 'lucide-svelte';
|
|
3
|
+
import github from './assets/github.svg';
|
|
4
|
+
import google from './assets/google.svg';
|
|
5
|
+
import microsoft from './assets/microsoft.svg';
|
|
6
|
+
import type { LoginProvider } from './contracts';
|
|
7
|
+
|
|
8
|
+
let {provider}: {provider: LoginProvider} = $props();
|
|
9
|
+
const marks = {github, google, microsoft};
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<!-- Brand marks: Simple Icons 11.15.0, CC0; see assets/LICENSE.txt. -->
|
|
13
|
+
<span class="flex h-10 w-10 shrink-0 items-center justify-center text-zinc-100" aria-hidden="true">
|
|
14
|
+
{#if provider === 'oidc'}
|
|
15
|
+
<Globe size={28} strokeWidth={1.5} />
|
|
16
|
+
{:else}
|
|
17
|
+
<img src={marks[provider]} alt="" class="h-7 w-7 brightness-0 invert" />
|
|
18
|
+
{/if}
|
|
19
|
+
</span>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { _, isLoading } from 'svelte-i18n';
|
|
4
|
+
import { v4 as uuid } from 'uuid';
|
|
5
|
+
import { requestPluginControl } from '$api/client';
|
|
6
|
+
import { logout, getAuthStateRevision, isAuthenticationStateCurrent } from '$stores/auth';
|
|
7
|
+
import { IndustrialToggle, IndustrialDialog, BSelect, StatusBadge } from '$components/industrial';
|
|
8
|
+
import { Button } from '$components/ui/button';
|
|
9
|
+
import { Input } from '$components/ui/input';
|
|
10
|
+
import { Plus } from 'lucide-svelte';
|
|
11
|
+
import type { Connection, ConnectionInput } from './contracts';
|
|
12
|
+
import ConnectionEditor from './ConnectionEditor.svelte';
|
|
13
|
+
import SettingsSection from './SettingsSection.svelte';
|
|
14
|
+
import TurnstileSettings from './TurnstileSettings.svelte';
|
|
15
|
+
let {pluginName}: {pluginName: string} = $props();
|
|
16
|
+
const id = $props.id();
|
|
17
|
+
const t = (key: string) => $isLoading ? '' : $_(`plugins.user-auth.${key}`);
|
|
18
|
+
const lifetime = new AbortController(), ownerRevision = getAuthStateRevision();
|
|
19
|
+
const current = () => !lifetime.signal.aborted && isAuthenticationStateCurrent(ownerRevision);
|
|
20
|
+
const call = <T,>(path: string, method: 'GET'|'POST'|'PUT'|'DELETE', body?: unknown) => requestPluginControl<T>(pluginName, path, method, body, lifetime.signal);
|
|
21
|
+
type LoginSettings = {version: number; autoCreateUsers: boolean};
|
|
22
|
+
let connections = $state<Connection[]>([]), settings = $state<LoginSettings | null>(null);
|
|
23
|
+
let editorOpen = $state(false), deleteOpen = $state(false);
|
|
24
|
+
let deleteTarget = $state<Connection | null>(null), search = $state(''), status = $state('all');
|
|
25
|
+
let visibleConnections = $derived(connections.filter(connection =>
|
|
26
|
+
(status === 'all' || connection.enabled === (status === 'enabled')) &&
|
|
27
|
+
[connection.label, connection.provider, connection.issuer, connection.clientId].some(value => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()))));
|
|
28
|
+
let editing = $state<Connection | null>(null), draftId = $state('');
|
|
29
|
+
let busy = $state(false), loading = $state(true), error = $state(''), notice = $state('');
|
|
30
|
+
let publicOrigin = $state<string | undefined>();
|
|
31
|
+
async function load() {
|
|
32
|
+
loading = true;
|
|
33
|
+
try { const [methods, login] = await Promise.all([call<{connections: Connection[]; publicOrigin: string | null}>('/login-connections', 'GET'), call<LoginSettings>('/login-settings', 'GET')]); if (current()) { connections = methods.connections; publicOrigin = methods.publicOrigin ?? undefined; settings = login; } }
|
|
34
|
+
finally { if (current()) loading = false; }
|
|
35
|
+
}
|
|
36
|
+
function edit(connection?: Connection) {
|
|
37
|
+
editing = connection ?? null; draftId = connection?.id ?? uuid();
|
|
38
|
+
editorOpen = true; error = ''; notice = '';
|
|
39
|
+
}
|
|
40
|
+
async function action(operation: () => Promise<void>) {
|
|
41
|
+
if (busy || !current()) return; busy = true; error = ''; notice = '';
|
|
42
|
+
try { await operation(); if (current()) notice ||= t('admin.saved'); }
|
|
43
|
+
catch (cause) { if (!current()) return; const code = cause instanceof Error ? cause.message : '';
|
|
44
|
+
if (code === 'version_conflict') { const editing = editorOpen, editingId = draftId; deleteOpen = false; deleteTarget = null; try { await load(); if (current() && editing) edit(connections.find(connection => connection.id === editingId)); } catch {} if (current()) error = t('admin.errors.version_conflict'); }
|
|
45
|
+
else error = ["invalid_input", "invalid_origin", "invalid_provider", "invalid_microsoft_configuration", "invalid_microsoft_tenant", "microsoft_account_scope", "microsoft_signing_key_issuer", "invalid_oidc_configuration", "invalid_oauth_identity", "oidc_metadata_failed", "oidc_metadata_invalid", "oidc_metadata_limit", "oidc_network_failed", "oidc_pkce_unsupported", "oidc_signing_alg_unsupported", "preset_scopes_fixed", "verified_identity_required", "invalid_oidc_url", "oidc_issuer_mismatch", "oidc_auth_method_unsupported", "oidc_secret_required"].includes(code) ? t(`admin.errors.${code}`) : t('settings.errors.failed');
|
|
46
|
+
} finally { if (current()) busy = false; }
|
|
47
|
+
}
|
|
48
|
+
async function saveConnection(input: ConnectionInput) { await action(async () => {
|
|
49
|
+
const result = await call<{connection: Connection}>('/login-connections', editing ? 'PUT' : 'POST', input);
|
|
50
|
+
if (!current()) return;
|
|
51
|
+
applyConnection(result.connection); editorOpen = false;
|
|
52
|
+
notice = t('connections.saved');
|
|
53
|
+
}); }
|
|
54
|
+
function confirmDelete(connection: Connection) { deleteTarget = connection; error = ''; notice = ''; deleteOpen = true; }
|
|
55
|
+
function applyConnection(connection: Connection) { if (current()) connections = [...connections.filter(item => item.id !== connection.id), connection]; }
|
|
56
|
+
async function toggleConnection(connection: Connection) { await action(async () => {
|
|
57
|
+
const result = await call<{connection: Connection}>('/login-connections', 'PUT', {
|
|
58
|
+
id: connection.id, version: connection.version, label: connection.label, enabled: !connection.enabled,
|
|
59
|
+
provider: connection.provider, configuration: connection.configuration, clientId: connection.clientId, ...(connection.provider === 'oidc' ? {scopes: connection.scopes} : {}),
|
|
60
|
+
});
|
|
61
|
+
applyConnection(result.connection);
|
|
62
|
+
}); }
|
|
63
|
+
async function removeConnection() { await action(async () => {
|
|
64
|
+
if (!deleteTarget) return;
|
|
65
|
+
const target = deleteTarget;
|
|
66
|
+
try {
|
|
67
|
+
const result = await call<{ok: true; sessionRevoked: boolean}>('/login-connections', 'DELETE', {id: target.id, version: target.version});
|
|
68
|
+
if (!current()) return;
|
|
69
|
+
if (result.sessionRevoked) { logout(); window.location.hash = '#/login'; return; }
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
if (!current()) return;
|
|
72
|
+
if (cause instanceof Error && cause.message === 'version_conflict') throw cause;
|
|
73
|
+
const result = await call<{connections: Connection[]}>('/login-connections', 'GET');
|
|
74
|
+
if (!current()) return;
|
|
75
|
+
connections = result.connections;
|
|
76
|
+
if (connections.some(connection => connection.id === target.id)) throw cause;
|
|
77
|
+
}
|
|
78
|
+
connections = connections.filter(connection => connection.id !== target.id); deleteOpen = false; deleteTarget = null;
|
|
79
|
+
}); }
|
|
80
|
+
async function toggleAuto(value: boolean) { await action(async () => { if (!settings) return; const result = await call<LoginSettings>('/login-settings', 'PUT', {version: settings.version, autoCreateUsers: value}); if (current()) settings = result; }); }
|
|
81
|
+
onMount(() => { void load().catch(() => { if (current()) error = t('settings.errors.failed'); }); });
|
|
82
|
+
onDestroy(() => { lifetime.abort(); });
|
|
83
|
+
</script>
|
|
84
|
+
<div data-testid="auth-login-settings">
|
|
85
|
+
<SettingsSection title={t('connections.title')}>
|
|
86
|
+
<div class="space-y-4">
|
|
87
|
+
{#if loading}<p class="text-sm text-zinc-400">{t('settings.loading')}</p>{:else}
|
|
88
|
+
<div class="flex items-start gap-3 border-b border-carbon-600 pb-4"><IndustrialToggle id={id + '-login-auto'} checked={settings?.autoCreateUsers ?? false} label={t('connections.autoCreate')} disabled={busy || !settings} onchange={toggleAuto} /><div class="min-w-0 pt-2"><label for={id + '-login-auto'} class="nx-field-label cursor-pointer">{t('connections.autoCreate')}</label><p class="text-sm text-zinc-400 mt-1">{t('connections.autoCreateWarning')}</p></div></div>
|
|
89
|
+
{#if connections.length}
|
|
90
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between" data-testid="login-connections-toolbar">
|
|
91
|
+
<Button variant="ghost" class="self-start shrink-0 gap-2" disabled={busy} onclick={() => edit()}><Plus class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />{t('connections.add')}</Button>
|
|
92
|
+
<div class="flex w-full min-w-0 flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center" data-testid="login-connections-filters">
|
|
93
|
+
<Input class="min-w-0 sm:w-80" type="search" bind:value={search} placeholder={t('connections.searchPlaceholder')} aria-label={t('connections.search')} />
|
|
94
|
+
<div class="w-full shrink-0 sm:w-auto sm:min-w-36"><BSelect autoWidth={false} bind:value={status} options={[{value: 'all', label: t('connections.all')}, {value: 'enabled', label: t('users.enabled')}, {value: 'disabled', label: t('users.disabled')}]} ariaLabel={t('connections.status')} /></div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="overflow-x-auto" role="region" aria-label={t('connections.title')}>
|
|
98
|
+
<table class="w-full min-w-[48rem] text-left text-base border border-carbon-600" aria-label={t('connections.title')}>
|
|
99
|
+
<thead class="bg-carbon-900 text-zinc-400"><tr>
|
|
100
|
+
<th class="px-2 py-3" scope="col">{t('connections.label')}</th><th class="px-2 py-3" scope="col">{t('connections.provider')}</th><th class="px-2 py-3" scope="col">{t('connections.clientId')}</th><th class="px-2 py-3" scope="col">{t('connections.status')}</th><th class="px-2 py-3" scope="col">{t('connections.actions')}</th>
|
|
101
|
+
</tr></thead>
|
|
102
|
+
<tbody>{#each visibleConnections as connection (connection.id)}
|
|
103
|
+
<tr class="border-t border-carbon-600" data-testid="login-connection-row">
|
|
104
|
+
<th class="max-w-48 break-words px-2 py-4 font-normal text-zinc-200" scope="row">{connection.label}</th>
|
|
105
|
+
<td class="max-w-64 break-all px-2 py-3 font-mono text-zinc-400">{t('providers.' + connection.provider)}</td>
|
|
106
|
+
<td class="px-2 py-3 text-zinc-300"><span class="block max-w-64 truncate font-mono text-sm" title={connection.clientId}>{connection.clientId}</span></td>
|
|
107
|
+
<td class="px-2 py-3"><StatusBadge variant={connection.enabled ? 'active' : 'muted'}>{connection.enabled ? t('users.enabled') : t('users.disabled')}</StatusBadge></td>
|
|
108
|
+
<td class="px-2 py-3"><div class="flex gap-2">
|
|
109
|
+
<Button variant="ghost" disabled={busy} onclick={() => edit(connection)}>{t('admin.edit')}</Button>
|
|
110
|
+
<Button variant="ghost" disabled={busy || (!connection.enabled && !publicOrigin)} title={!connection.enabled && !publicOrigin ? t('connections.missingOrigin') : undefined} onclick={() => toggleConnection(connection)}>{t(connection.enabled ? 'connections.disable' : 'connections.enable')}</Button>
|
|
111
|
+
<Button variant="destructive" disabled={busy} onclick={() => confirmDelete(connection)}>{t('connections.remove')}</Button>
|
|
112
|
+
</div></td>
|
|
113
|
+
</tr>
|
|
114
|
+
{:else}<tr><td colspan="5" class="px-2 py-6 text-zinc-400">{t('connections.noMatches')}</td></tr>{/each}</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>
|
|
117
|
+
{:else}
|
|
118
|
+
<div class="flex flex-col items-start gap-3 py-4" data-testid="login-connections-empty">
|
|
119
|
+
<p class="text-sm text-zinc-400">{t('connections.empty')}</p>
|
|
120
|
+
<Button class="gap-2" disabled={busy} onclick={() => edit()}><Plus class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />{t('connections.add')}</Button>
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
{/if}
|
|
125
|
+
</div>
|
|
126
|
+
</SettingsSection>
|
|
127
|
+
<TurnstileSettings {pluginName} />
|
|
128
|
+
{#if notice && !editorOpen && !deleteOpen}<p class="text-sm text-emerald-400" role="status">{notice}</p>{/if}
|
|
129
|
+
{#if error && !editorOpen && !deleteOpen}<p class="text-sm text-red-400" role="alert">{error}</p><Button variant="ghost" disabled={busy} onclick={() => action(load)}>{t('session.reload')}</Button>{/if}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{#if editorOpen}
|
|
133
|
+
{#key `${draftId}:${editing?.version ?? 0}`}<ConnectionEditor bind:open={editorOpen} {pluginName} connection={editing} connectionId={draftId} {publicOrigin} {busy} {error} onsave={saveConnection} />{/key}
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
{#snippet deleteBody()}
|
|
137
|
+
<p class="break-words text-sm text-zinc-200">{deleteTarget?.label}</p>
|
|
138
|
+
{#if error}<p role="alert" class="text-sm text-red-400">{error}</p>{/if}
|
|
139
|
+
{/snippet}
|
|
140
|
+
{#snippet deleteFooter()}<Button variant="destructive" disabled={busy || !deleteTarget} onclick={removeConnection}>{t('connections.remove')}</Button>{/snippet}
|
|
141
|
+
<IndustrialDialog bind:open={deleteOpen} title={t('connections.delete')} description={t('connections.deleteWarning')} {busy} closeLabel={t('admin.cancel')} body={deleteBody} footer={deleteFooter} onOpenChange={value => {if (!value) {deleteTarget = null; error = '';}}} />
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { _, isLoading } from 'svelte-i18n';
|
|
4
|
+
import { requestPluginControl } from '$api/client';
|
|
5
|
+
import { endSession } from '$api/auth';
|
|
6
|
+
import { logout, getAuthStateRevision, isAuthenticationStateCurrent } from '$stores/auth';
|
|
7
|
+
import { PanelCard, LoadingIndicator, StatusBadge, IndustrialDialog } from '$components/industrial';
|
|
8
|
+
import { Button } from '$components/ui/button';
|
|
9
|
+
import LoginProviderIcon from './LoginProviderIcon.svelte';
|
|
10
|
+
import { validPassword, authorizationUrl, type User, type Connection, type Identity } from './contracts';
|
|
11
|
+
|
|
12
|
+
let { pluginName = 'user-auth' }: { pluginName?: string; apiBase?: string } = $props();
|
|
13
|
+
const id = $props.id();
|
|
14
|
+
const t = (key: string) => $isLoading ? '' : $_(`plugins.user-auth.${key}`);
|
|
15
|
+
const lifetime = new AbortController();
|
|
16
|
+
const revision = getAuthStateRevision();
|
|
17
|
+
const current = () => !lifetime.signal.aborted && isAuthenticationStateCurrent(revision);
|
|
18
|
+
const call = <T,>(path: string, method: 'GET' | 'POST' | 'DELETE', body?: unknown) => requestPluginControl<T>(pluginName, path, method, body, lifetime.signal);
|
|
19
|
+
let administrator = $state<User | null>(null), identities = $state<Identity[]>([]), connections = $state<Connection[]>([]);
|
|
20
|
+
let currentPassword = $state(''), password = $state(''), confirmation = $state('');
|
|
21
|
+
let busy = $state(false), loading = $state(true), error = $state(''), detail = $state('');
|
|
22
|
+
let passwordOpen = $state(false), unlinkTarget = $state<Identity | null>(null), unlinkOpen = $state(false);
|
|
23
|
+
let bindingRows = $derived(connections
|
|
24
|
+
.filter(connection => connection.enabled || identities.some(identity => identity.connectionId === connection.id))
|
|
25
|
+
.map(connection => ({connection, identities: identities.filter(identity => identity.connectionId === connection.id)})));
|
|
26
|
+
let unmatchedIdentities = $derived(identities.filter(identity => !connections.some(connection => connection.id === identity.connectionId)));
|
|
27
|
+
let unlinkConnection = $derived(connections.find(connection => connection.id === unlinkTarget?.connectionId));
|
|
28
|
+
function failure(cause: unknown) {
|
|
29
|
+
detail = cause instanceof Error ? cause.message : String(cause);
|
|
30
|
+
const key = ['invalid_password', 'password_confirmation', 'password_unchanged'].includes(detail) ? 'settings.errors.' + detail
|
|
31
|
+
: ['management_origin_required', 'invalid_origin', 'invalid_oidc_url', 'oidc_unavailable', 'oidc_network_failed', 'oidc_metadata_failed', 'oidc_metadata_invalid', 'oidc_pkce_unsupported', 'identity_exists'].includes(detail) ? 'admin.errors.' + detail : 'settings.errors.failed';
|
|
32
|
+
error = key;
|
|
33
|
+
}
|
|
34
|
+
function signedOut() { if (current()) { logout(); window.location.hash = '#/login'; } }
|
|
35
|
+
async function load() {
|
|
36
|
+
loading = true; error = ''; detail = '';
|
|
37
|
+
try {
|
|
38
|
+
const result = await call<{administrator: User}>('/self', 'GET');
|
|
39
|
+
if (!current()) return;
|
|
40
|
+
administrator = result.administrator;
|
|
41
|
+
const [bound, methods] = await Promise.all([
|
|
42
|
+
call<{identities: Identity[]}>(`/identities?userId=${encodeURIComponent(result.administrator.id)}`, 'GET'),
|
|
43
|
+
call<{connections: Connection[]}>('/login-connections', 'GET'),
|
|
44
|
+
]);
|
|
45
|
+
if (current()) { identities = bound.identities; connections = methods.connections; }
|
|
46
|
+
} catch (cause) { if (current()) failure(cause); }
|
|
47
|
+
finally { if (current()) loading = false; }
|
|
48
|
+
}
|
|
49
|
+
async function action(operation: () => Promise<void>) {
|
|
50
|
+
if (busy || !current()) return;
|
|
51
|
+
busy = true; error = ''; detail = '';
|
|
52
|
+
try { await operation(); } catch (cause) { if (current()) failure(cause); }
|
|
53
|
+
finally { if (current()) busy = false; }
|
|
54
|
+
}
|
|
55
|
+
async function change(event: SubmitEvent) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
await action(async () => {
|
|
58
|
+
validPassword(password, confirmation);
|
|
59
|
+
try {
|
|
60
|
+
await requestPluginControl(pluginName, '/password', 'POST', {currentPassword, password, passwordConfirmation: confirmation}, lifetime.signal, {preserveSessionOnUnauthorized: true});
|
|
61
|
+
signedOut();
|
|
62
|
+
} finally { currentPassword = ''; password = ''; confirmation = ''; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function leave() { await action(async () => { await endSession({onCompleted: () => { if (!lifetime.signal.aborted) window.location.hash = '#/login'; }}); }); }
|
|
66
|
+
async function link(connection: Connection) { await action(async () => {
|
|
67
|
+
if (!connection.enabled || identities.some(identity => identity.connectionId === connection.id)) return;
|
|
68
|
+
const result = await call<{authorizationUrl: string}>('/identities/link/start', 'POST', {connectionId: connection.id});
|
|
69
|
+
if (current()) window.location.assign(authorizationUrl(result.authorizationUrl));
|
|
70
|
+
}); }
|
|
71
|
+
function confirmUnbind(identity: Identity) { error = ''; detail = ''; unlinkTarget = identity; unlinkOpen = true; }
|
|
72
|
+
async function unbind(identity: Identity) { await action(async () => { await call('/identities', 'DELETE', {id: identity.id}); signedOut(); }); }
|
|
73
|
+
onMount(() => {
|
|
74
|
+
const flowId = new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('oidcLink');
|
|
75
|
+
if (flowId) {
|
|
76
|
+
window.history.replaceState(null, '', window.location.pathname + window.location.search + window.location.hash.split('?')[0]);
|
|
77
|
+
loading = false;
|
|
78
|
+
void action(async () => { await call('/identities/link/finish', 'POST', {flowId}); signedOut(); });
|
|
79
|
+
} else void load();
|
|
80
|
+
});
|
|
81
|
+
onDestroy(() => { lifetime.abort(); currentPassword = ''; password = ''; confirmation = ''; });
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<div class="space-y-4" data-testid="auth-user-profile">
|
|
85
|
+
<div class="space-y-2">
|
|
86
|
+
<h1 class="nx-display text-xl text-zinc-50">{t('profile.title')}</h1>
|
|
87
|
+
</div>
|
|
88
|
+
<PanelCard title={t('settings.accountTitle')} tag={t('settings.accountTag')}>
|
|
89
|
+
<div data-testid="account-summary">
|
|
90
|
+
{#if loading}<LoadingIndicator label={t('settings.loading')} height="sm" />
|
|
91
|
+
{:else if administrator}
|
|
92
|
+
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
|
93
|
+
<div class="min-w-0 space-y-3" data-testid="account-details">
|
|
94
|
+
<dl class="space-y-2">
|
|
95
|
+
<div><dt class="sr-only">{t('login.username')}</dt><dd class="break-all text-xl font-semibold text-zinc-100">{administrator.username}</dd></div>
|
|
96
|
+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm"><dt class="text-zinc-400">{t('users.displayName')}</dt><dd class="min-w-0 break-words text-zinc-300">{administrator.displayName || administrator.username}</dd></div>
|
|
97
|
+
</dl>
|
|
98
|
+
<StatusBadge variant="active">{t(administrator.hasLocalPassword ? 'users.local' : 'users.sso')}</StatusBadge>
|
|
99
|
+
{#if !administrator.hasLocalPassword}<p class="max-w-xl text-sm text-zinc-400">{t('profile.noPassword')}</p>{/if}
|
|
100
|
+
</div>
|
|
101
|
+
<div class="flex shrink-0 flex-col gap-2 sm:border-l sm:border-carbon-600 sm:pl-6" data-testid="account-actions">
|
|
102
|
+
{#if administrator.hasLocalPassword}<Button class="w-full sm:min-w-32" disabled={busy} onclick={() => {error = ''; detail = ''; passwordOpen = true;}}>{t('profile.changePassword')}</Button>{/if}
|
|
103
|
+
<Button class="w-full sm:min-w-32" variant="ghost" disabled={busy} onclick={leave}>{t('settings.logout')}</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
</PanelCard>
|
|
109
|
+
<PanelCard title={t('users.identities')} tag="SSO">
|
|
110
|
+
<div class="space-y-4" data-testid="identity-bindings">
|
|
111
|
+
<p class="text-sm text-zinc-400">{t('profile.bindingDescription')}</p>
|
|
112
|
+
{#if loading}<LoadingIndicator label={t('settings.loading')} height="sm" />
|
|
113
|
+
{:else if bindingRows.length || unmatchedIdentities.length}
|
|
114
|
+
<ul class="divide-y divide-carbon-600" aria-label={t('users.identities')}>
|
|
115
|
+
{#each bindingRows as row (row.connection.id)}
|
|
116
|
+
{@const connection = row.connection}
|
|
117
|
+
<li class="flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center" data-connection-id={connection.id}>
|
|
118
|
+
<div class="flex min-w-0 items-center gap-3 sm:w-1/3 sm:shrink-0">
|
|
119
|
+
<LoginProviderIcon provider={connection.provider} />
|
|
120
|
+
<div class="min-w-0 flex-1 space-y-1">
|
|
121
|
+
<h3 class="break-words text-base font-semibold text-zinc-100">{connection.provider === 'oidc' ? connection.label : t('providers.' + connection.provider)}</h3>
|
|
122
|
+
{#if connection.provider === 'oidc' || connection.label !== t('providers.' + connection.provider)}
|
|
123
|
+
<p class="break-words text-sm text-zinc-400">{connection.provider === 'oidc' ? t('providers.oidc') : connection.label}</p>
|
|
124
|
+
{/if}
|
|
125
|
+
{#if !row.identities.length}<p class="text-sm text-zinc-400">{t('profile.notBound')}</p>{/if}
|
|
126
|
+
{#if !connection.enabled}<StatusBadge variant="muted">{t('connections.disable')}</StatusBadge>{/if}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="min-w-0 flex-1 space-y-3">
|
|
130
|
+
{#if !row.identities.length}
|
|
131
|
+
<div class="flex justify-end"><Button variant="ghost" disabled={busy || !administrator || !connection.enabled} aria-label={`${t('profile.bind')} ${connection.label}`} onclick={() => link(connection)}>{t('profile.bind')}</Button></div>
|
|
132
|
+
{:else}
|
|
133
|
+
{#each row.identities as identity (identity.id)}
|
|
134
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
135
|
+
<p class="min-w-0 flex-1 break-all text-sm text-zinc-400">{t('profile.identityId')}<span class="ml-2 font-mono text-zinc-300">{identity.subject}</span></p>
|
|
136
|
+
<StatusBadge variant="active">{t('profile.bound')}</StatusBadge>
|
|
137
|
+
<Button variant="destructive" disabled={busy || !administrator} aria-label={`${t('users.unbind')} ${connection.label} ${identity.subject}`} onclick={() => confirmUnbind(identity)}>{t('users.unbind')}</Button>
|
|
138
|
+
</div>
|
|
139
|
+
{/each}
|
|
140
|
+
{/if}
|
|
141
|
+
</div>
|
|
142
|
+
</li>
|
|
143
|
+
{/each}
|
|
144
|
+
{#each unmatchedIdentities as identity (identity.id)}
|
|
145
|
+
<li class="flex flex-wrap items-center gap-3 py-4 last:pb-0">
|
|
146
|
+
<LoginProviderIcon provider="oidc" />
|
|
147
|
+
<div class="min-w-0 flex-1 space-y-1"><h3 class="break-all text-base text-zinc-100">{identity.issuer}</h3><p class="break-all text-sm text-zinc-400">{t('profile.identityId')} <span class="font-mono">{identity.subject}</span></p></div>
|
|
148
|
+
<Button variant="destructive" disabled={busy || !administrator} aria-label={`${t('users.unbind')} ${identity.subject}`} onclick={() => confirmUnbind(identity)}>{t('users.unbind')}</Button>
|
|
149
|
+
</li>
|
|
150
|
+
{/each}
|
|
151
|
+
</ul>
|
|
152
|
+
<p class="border-t border-carbon-600 pt-3 text-sm text-zinc-400">{t('users.bindingNote')}</p>
|
|
153
|
+
{:else}<p class="text-sm text-zinc-400">{t('profile.noConnections')}</p>{/if}
|
|
154
|
+
</div>
|
|
155
|
+
</PanelCard>
|
|
156
|
+
{#if error && !passwordOpen && !unlinkOpen}
|
|
157
|
+
<p role="alert" class="text-sm text-red-400">{t(error)}</p>
|
|
158
|
+
<details class="text-sm text-zinc-400"><summary>{t('login.technicalDetails')}</summary><pre class="whitespace-pre-wrap break-all">{detail}</pre></details>
|
|
159
|
+
<Button variant="ghost" disabled={busy} onclick={load}>{t('session.reload')}</Button>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{#snippet passwordBody()}
|
|
164
|
+
<form id={id + '-password'} class="space-y-3" onsubmit={change} aria-busy={busy} aria-label={t('settings.passwordTitle')}>
|
|
165
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('settings.currentPassword')}</span><input class="nx-input" type="password" autocomplete="current-password" bind:value={currentPassword} required disabled={busy || !administrator} /></label>
|
|
166
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('settings.newPassword')}</span><input class="nx-input" type="password" autocomplete="new-password" bind:value={password} required disabled={busy || !administrator} /></label>
|
|
167
|
+
<label class="grid gap-1"><span class="nx-field-label">{t('settings.confirmation')}</span><input class="nx-input" type="password" autocomplete="new-password" bind:value={confirmation} required disabled={busy || !administrator} /></label>
|
|
168
|
+
{#if error}<p role="alert" class="text-sm text-red-400">{t(error)}</p>{/if}
|
|
169
|
+
</form>
|
|
170
|
+
{/snippet}
|
|
171
|
+
{#snippet passwordFooter()}<Button type="submit" form={id + '-password'} disabled={busy || !administrator}>{busy ? t('settings.updating') : t('settings.submit')}</Button>{/snippet}
|
|
172
|
+
<IndustrialDialog bind:open={passwordOpen} title={t('settings.passwordTitle')} description={t('settings.passwordDescription')} {busy} closeLabel={t('admin.close')} body={passwordBody} footer={passwordFooter} scrollBody onOpenChange={value => {if (!value) {currentPassword = ''; password = ''; confirmation = ''; error = ''; detail = '';}}} />
|
|
173
|
+
|
|
174
|
+
{#snippet unlinkBody()}
|
|
175
|
+
{#if unlinkTarget}
|
|
176
|
+
<div class="flex items-start gap-3">
|
|
177
|
+
<LoginProviderIcon provider={unlinkConnection?.provider ?? 'oidc'} />
|
|
178
|
+
<div class="min-w-0 space-y-1"><p class="break-words font-semibold text-zinc-100">{unlinkConnection?.label ?? unlinkTarget.issuer}</p><p class="break-all text-sm text-zinc-400">{t('profile.identityId')} <span class="font-mono">{unlinkTarget.subject}</span></p></div>
|
|
179
|
+
</div>
|
|
180
|
+
<p class="text-sm text-zinc-300">{t('profile.unbindWarning')}</p>
|
|
181
|
+
{/if}
|
|
182
|
+
{#if error}<p role="alert" class="text-sm text-red-400">{t(error)}</p>{/if}
|
|
183
|
+
{/snippet}
|
|
184
|
+
{#snippet unlinkFooter()}<Button type="button" variant="destructive" disabled={busy || !administrator || !unlinkTarget} onclick={() => {if (unlinkTarget) void unbind(unlinkTarget);}}>{t('users.unbind')}</Button>{/snippet}
|
|
185
|
+
<IndustrialDialog bind:open={unlinkOpen} title={t('profile.unbindTitle')} description="" {busy} closeLabel={t('admin.cancel')} body={unlinkBody} footer={unlinkFooter} scrollBody onOpenChange={value => {if (!value) {unlinkTarget = null; error = ''; detail = '';}}} />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { isLoading } from 'svelte-i18n';
|
|
3
3
|
import { _ } from '@bungee/plugin-sdk';
|
|
4
|
-
const t = (key: string) => $isLoading ? '' : $_(`plugins.
|
|
4
|
+
const t = (key: string) => $isLoading ? '' : $_(`plugins.user-auth.recovery.${key}`);
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
7
|
<details class="text-sm text-zinc-400 space-y-2">
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<li>{t('step2')}</li>
|
|
13
13
|
<li>{t('step3')}</li>
|
|
14
14
|
</ol>
|
|
15
|
-
<code class="block border border-carbon-600 p-3 font-mono text-xs whitespace-pre-wrap break-all">
|
|
15
|
+
<code class="block border border-carbon-600 p-3 font-mono text-xs whitespace-pre-wrap break-all">bungee recover --plugin user-auth --action reset</code>
|
|
16
16
|
<p>{t('docker')}</p>
|
|
17
|
-
<
|
|
17
|
+
<code class="block border border-carbon-600 p-3 font-mono text-xs whitespace-pre-wrap break-all">bun packages/core/dist/main.js --recover /usr/app/data/bungee.db --plugin user-auth --action reset</code>
|
|
18
18
|
</details>
|