488 lines
19 KiB
TypeScript
488 lines
19 KiB
TypeScript
import { Component, Show, createSignal, onMount } from 'solid-js';
|
|
import { createStore } from 'solid-js/store';
|
|
import { Card } from '@/components/shared/Card';
|
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
|
import { Toggle } from '@/components/shared/Toggle';
|
|
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
|
import { notificationStore } from '@/stores/notifications';
|
|
|
|
interface OIDCConfigResponse {
|
|
enabled: boolean;
|
|
issuerUrl: string;
|
|
clientId: string;
|
|
redirectUrl: string;
|
|
logoutUrl: string;
|
|
scopes: string[];
|
|
usernameClaim: string;
|
|
emailClaim: string;
|
|
groupsClaim: string;
|
|
allowedGroups: string[];
|
|
allowedDomains: string[];
|
|
allowedEmails: string[];
|
|
clientSecretSet: boolean;
|
|
envOverrides?: Record<string, boolean>;
|
|
defaultRedirect: string;
|
|
}
|
|
|
|
const listToString = (values?: string[]) => (values && values.length > 0 ? values.join(', ') : '');
|
|
const splitList = (input: string) =>
|
|
input
|
|
.split(/[,\s]+/)
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
|
|
interface Props {
|
|
onConfigUpdated?: (config: OIDCConfigResponse) => void;
|
|
}
|
|
|
|
export const OIDCPanel: Component<Props> = (props) => {
|
|
const [config, setConfig] = createSignal<OIDCConfigResponse | null>(null);
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [advancedOpen, setAdvancedOpen] = createSignal(false);
|
|
|
|
const [form, setForm] = createStore({
|
|
enabled: false,
|
|
issuerUrl: '',
|
|
clientId: '',
|
|
redirectUrl: '',
|
|
logoutUrl: '',
|
|
scopes: '',
|
|
usernameClaim: 'preferred_username',
|
|
emailClaim: 'email',
|
|
groupsClaim: '',
|
|
allowedGroups: '',
|
|
allowedDomains: '',
|
|
allowedEmails: '',
|
|
clientSecret: '',
|
|
clearSecret: false,
|
|
});
|
|
|
|
const isEnvLocked = () => {
|
|
const env = config()?.envOverrides;
|
|
return env ? Object.keys(env).length > 0 : false;
|
|
};
|
|
|
|
const resetForm = (data: OIDCConfigResponse | null) => {
|
|
if (!data) {
|
|
setForm({
|
|
enabled: false,
|
|
issuerUrl: '',
|
|
clientId: '',
|
|
redirectUrl: '',
|
|
logoutUrl: '',
|
|
scopes: '',
|
|
usernameClaim: 'preferred_username',
|
|
emailClaim: 'email',
|
|
groupsClaim: '',
|
|
allowedGroups: '',
|
|
allowedDomains: '',
|
|
allowedEmails: '',
|
|
clientSecret: '',
|
|
clearSecret: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setForm({
|
|
enabled: data.enabled,
|
|
issuerUrl: data.issuerUrl ?? '',
|
|
clientId: data.clientId ?? '',
|
|
redirectUrl: data.redirectUrl || data.defaultRedirect || '',
|
|
logoutUrl: data.logoutUrl ?? '',
|
|
scopes: data.scopes?.join(' ') ?? 'openid profile email',
|
|
usernameClaim: data.usernameClaim || 'preferred_username',
|
|
emailClaim: data.emailClaim || 'email',
|
|
groupsClaim: data.groupsClaim ?? '',
|
|
allowedGroups: listToString(data.allowedGroups),
|
|
allowedDomains: listToString(data.allowedDomains),
|
|
allowedEmails: listToString(data.allowedEmails),
|
|
clientSecret: '',
|
|
clearSecret: false,
|
|
});
|
|
};
|
|
|
|
const loadConfig = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { apiFetch } = await import('@/utils/apiClient');
|
|
const response = await apiFetch('/api/security/oidc');
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load OIDC settings (${response.status})`);
|
|
}
|
|
const data = (await response.json()) as OIDCConfigResponse;
|
|
setConfig(data);
|
|
resetForm(data);
|
|
props.onConfigUpdated?.(data);
|
|
} catch (error) {
|
|
console.error('[OIDCPanel] Failed to load config:', error);
|
|
notificationStore.error('Failed to load OIDC settings');
|
|
setConfig(null);
|
|
resetForm(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
onMount(() => {
|
|
loadConfig();
|
|
});
|
|
|
|
const handleSave = async (event?: Event) => {
|
|
event?.preventDefault();
|
|
if (isEnvLocked()) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
enabled: form.enabled,
|
|
issuerUrl: form.issuerUrl.trim(),
|
|
clientId: form.clientId.trim(),
|
|
redirectUrl: form.redirectUrl.trim(),
|
|
logoutUrl: form.logoutUrl.trim(),
|
|
scopes: splitList(form.scopes),
|
|
usernameClaim: form.usernameClaim.trim(),
|
|
emailClaim: form.emailClaim.trim(),
|
|
groupsClaim: form.groupsClaim.trim(),
|
|
allowedGroups: splitList(form.allowedGroups),
|
|
allowedDomains: splitList(form.allowedDomains),
|
|
allowedEmails: splitList(form.allowedEmails),
|
|
};
|
|
|
|
if (form.clientSecret.trim() !== '') {
|
|
payload.clientSecret = form.clientSecret.trim();
|
|
} else if (form.clearSecret) {
|
|
payload.clearClientSecret = true;
|
|
}
|
|
|
|
const { apiFetch } = await import('@/utils/apiClient');
|
|
const response = await apiFetch('/api/security/oidc', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.text();
|
|
throw new Error(message || `Failed to save OIDC settings (${response.status})`);
|
|
}
|
|
|
|
const updated = (await response.json()) as OIDCConfigResponse;
|
|
setConfig(updated);
|
|
resetForm(updated);
|
|
notificationStore.success('OIDC settings updated');
|
|
props.onConfigUpdated?.(updated);
|
|
} catch (error) {
|
|
console.error('[OIDCPanel] Failed to save config:', error);
|
|
notificationStore.error('Failed to save OIDC settings');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
padding="none"
|
|
class="overflow-hidden border border-gray-200 dark:border-gray-700"
|
|
border={false}
|
|
>
|
|
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
|
<svg
|
|
class="w-5 h-5 text-blue-600 dark:text-blue-300"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.8"
|
|
d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<SectionHeader
|
|
title="Single sign-on (OIDC)"
|
|
description="Connect Pulse to your identity provider"
|
|
size="sm"
|
|
class="flex-1"
|
|
/>
|
|
<Toggle
|
|
checked={form.enabled}
|
|
onChange={(event) => {
|
|
setForm('enabled', event.currentTarget.checked);
|
|
}}
|
|
disabled={isEnvLocked() || loading() || saving()}
|
|
containerClass="items-center gap-2"
|
|
label={
|
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
|
{form.enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<form class="p-6 space-y-5" onSubmit={handleSave}>
|
|
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3 text-xs text-blue-800 dark:text-blue-200">
|
|
<ol class="space-y-1 list-decimal pl-4">
|
|
<li>Set PUBLIC_URL environment variable</li>
|
|
<li>Register client with your IdP using redirect URL below</li>
|
|
<li>Enter issuer and client ID (client secret optional for PKCE)</li>
|
|
<li>Save and test with SSO button on login page</li>
|
|
</ol>
|
|
</div>
|
|
<Show when={loading()}>
|
|
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
|
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
Loading OIDC settings...
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!loading()}>
|
|
<Show when={isEnvLocked()}>
|
|
<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded p-3 text-xs text-amber-800 dark:text-amber-200">
|
|
<strong>Managed by environment variables:</strong> OIDC settings are currently defined
|
|
through environment variables. Edit the deployment configuration to make changes.
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Issuer URL</label>
|
|
<input
|
|
type="url"
|
|
value={form.issuerUrl}
|
|
onInput={(event) => setForm('issuerUrl', event.currentTarget.value)}
|
|
placeholder="https://login.example.com/realms/pulse"
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
required
|
|
/>
|
|
<p class={formHelpText}>Base issuer URL from your OIDC provider configuration.</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Client ID</label>
|
|
<input
|
|
type="text"
|
|
value={form.clientId}
|
|
onInput={(event) => setForm('clientId', event.currentTarget.value)}
|
|
placeholder="pulse-client"
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
required
|
|
/>
|
|
</div>
|
|
<div class={formField}>
|
|
<div class="flex items-center justify-between">
|
|
<label class={labelClass('mb-0')}>Client secret</label>
|
|
<Show when={config()?.clientSecretSet}>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-blue-600 hover:underline dark:text-blue-300"
|
|
onClick={() => {
|
|
if (!isEnvLocked() && !saving()) {
|
|
setForm('clientSecret', '');
|
|
setForm('clearSecret', true);
|
|
notificationStore.info('Client secret will be cleared on save', 2500);
|
|
}
|
|
}}
|
|
disabled={isEnvLocked() || saving()}
|
|
>
|
|
Clear stored secret
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
<input
|
|
type="password"
|
|
value={form.clientSecret}
|
|
onInput={(event) => {
|
|
setForm('clientSecret', event.currentTarget.value);
|
|
if (event.currentTarget.value.trim() !== '') {
|
|
setForm('clearSecret', false);
|
|
}
|
|
}}
|
|
placeholder={
|
|
config()?.clientSecretSet
|
|
? '•••••••• (leave blank to keep existing)'
|
|
: 'Enter client secret'
|
|
}
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Optional - Leave blank if your provider supports PKCE (Zitadel, Authentik, etc). Otherwise, enter your client secret.
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Redirect URL</label>
|
|
<input
|
|
type="url"
|
|
value={form.redirectUrl}
|
|
onInput={(event) => setForm('redirectUrl', event.currentTarget.value)}
|
|
placeholder={config()?.defaultRedirect || ''}
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
{config()?.defaultRedirect && config()?.defaultRedirect.trim() !== ''
|
|
? `Optional - Leave blank to auto-detect from request headers (supports reverse proxies). Detected URL: ${config()?.defaultRedirect}`
|
|
: 'Optional - Leave blank to auto-detect from request headers. For best results, set PUBLIC_URL environment variable.'}
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Logout URL</label>
|
|
<input
|
|
type="url"
|
|
value={form.logoutUrl}
|
|
onInput={(event) => setForm('logoutUrl', event.currentTarget.value)}
|
|
placeholder="https://auth.example.com/application/o/pulse/end-session/"
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Optional - OIDC end-session URL for proper logout (e.g., Authentik's end-session endpoint). Leave blank to use local logout only.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<button
|
|
type="button"
|
|
class="text-xs font-semibold text-blue-600 hover:underline dark:text-blue-300"
|
|
onClick={() => setAdvancedOpen(!advancedOpen())}
|
|
>
|
|
{advancedOpen() ? 'Hide advanced OIDC options' : 'Show advanced OIDC options'}
|
|
</button>
|
|
|
|
<Show when={advancedOpen()}>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Scopes</label>
|
|
<input
|
|
type="text"
|
|
value={form.scopes}
|
|
onInput={(event) => setForm('scopes', event.currentTarget.value)}
|
|
placeholder="openid profile email"
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>Space-separated list of scopes requested during login.</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Username claim</label>
|
|
<input
|
|
type="text"
|
|
value={form.usernameClaim}
|
|
onInput={(event) => setForm('usernameClaim', event.currentTarget.value)}
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Claim used to populate the Pulse username (default: preferred_username).
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Email claim</label>
|
|
<input
|
|
type="text"
|
|
value={form.emailClaim}
|
|
onInput={(event) => setForm('emailClaim', event.currentTarget.value)}
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Groups claim</label>
|
|
<input
|
|
type="text"
|
|
value={form.groupsClaim}
|
|
onInput={(event) => setForm('groupsClaim', event.currentTarget.value)}
|
|
class={controlClass()}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Optional claim that lists group memberships. Used for group restrictions.
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Allowed groups</label>
|
|
<textarea
|
|
rows={2}
|
|
value={form.allowedGroups}
|
|
onInput={(event) => setForm('allowedGroups', event.currentTarget.value)}
|
|
placeholder="admin, sso-admins"
|
|
class={controlClass('min-h-[70px]')}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Comma or space separated values. Leave empty to allow any group.
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Allowed domains</label>
|
|
<textarea
|
|
rows={2}
|
|
value={form.allowedDomains}
|
|
onInput={(event) => setForm('allowedDomains', event.currentTarget.value)}
|
|
placeholder="example.com, partner.io"
|
|
class={controlClass('min-h-[70px]')}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>
|
|
Restrict access to email domains (without @). Leave empty to allow all.
|
|
</p>
|
|
</div>
|
|
<div class={formField}>
|
|
<label class={labelClass()}>Allowed email addresses</label>
|
|
<textarea
|
|
rows={2}
|
|
value={form.allowedEmails}
|
|
onInput={(event) => setForm('allowedEmails', event.currentTarget.value)}
|
|
placeholder="admin@example.com"
|
|
class={controlClass('min-h-[70px]')}
|
|
disabled={isEnvLocked() || saving()}
|
|
/>
|
|
<p class={formHelpText}>Optional allowlist of specific emails.</p>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3 pt-4">
|
|
<Show when={config()?.defaultRedirect}>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
Redirect URL registered with your IdP must match Pulse:{' '}
|
|
{config()?.defaultRedirect}
|
|
</div>
|
|
</Show>
|
|
<div class="flex gap-3">
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
onClick={() => resetForm(config())}
|
|
disabled={saving() || loading()}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={saving() || loading() || isEnvLocked()}
|
|
>
|
|
{saving() ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</form>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default OIDCPanel;
|