Pulse/frontend-modern/src/components/Settings/OIDCPanel.tsx
2025-10-11 23:29:47 +00:00

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;