feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak, Authentik, …) alongside local accounts. Resolves the feature request in #73. Flow (backend-driven Authorization Code + PKCE): - GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce transaction in Redis (single-use, 10m TTL); the callback validates the ID token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the identity to a local user, and mints the platform's normal session JWT — handed to the SPA via URL fragment. No sessions/cookies introduced. Identity mapping (users table gains email/oidc_subject/oidc_issuer/ auth_provider; password_hash now nullable; partial unique indexes): - First sign-in auto-provisions a user, or links to an existing local account when the IdP email is verified and matches. - Roles derive from a configurable IdP group -> role map with a default fallback; existing users are never auto-demoted on a groupless login. Configuration (admin-only, no env/redeploy needed): - Stored as an app_settings row with the client secret encrypted at rest (utils/crypto); masked in responses and excluded from GET /api/settings. - New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role mapping, provisioning toggles, discovery test, copyable redirect URI), a login-page SSO button, and an SSO badge in Users & Roles. Security: local break-glass login preserved; PKCE + single-use state; email linking requires email_verified; same-site-only post-login redirect. Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping, username derivation, config secret round-trip/masking); 114 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ea1ad956ff
commit
c0f230bdf2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.12-beta",
|
||||
"version": "0.17.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,7 @@ import TopologyPage from './pages/TopologyPage';
|
||||
import BackupsPage from './pages/BackupsPage';
|
||||
import FirmwarePage from './pages/FirmwarePage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import OidcCallbackPage from './pages/OidcCallbackPage';
|
||||
import WirelessPage from './pages/WirelessPage';
|
||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||
import GuestWifiPage from './pages/GuestWifiPage';
|
||||
@@ -37,6 +38,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth/callback" element={<OidcCallbackPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { KeyRound, Plus, Trash2, Copy, Check, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { oidcApi, type OidcConfigView } from '../../services/api';
|
||||
import type { UserRole } from '../../types';
|
||||
|
||||
const ROLES: UserRole[] = ['viewer', 'operator', 'admin'];
|
||||
type GroupRow = { group: string; role: UserRole };
|
||||
|
||||
export default function OidcSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['oidc-config'],
|
||||
queryFn: () => oidcApi.getConfig().then((r) => r.data),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Single sign-on can only be configured by administrators.</div>;
|
||||
}
|
||||
if (!data) return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Loading…</div>;
|
||||
|
||||
// Remount when the loaded config identity changes so form state re-initializes.
|
||||
return <OidcForm key={data.redirect_uri} initial={data} />;
|
||||
}
|
||||
|
||||
function OidcForm({ initial }: { initial: OidcConfigView }) {
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<OidcConfigView>(initial);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [groupRows, setGroupRows] = useState<GroupRow[]>(
|
||||
Object.entries(initial.group_role_map || {}).map(([group, role]) => ({ group, role }))
|
||||
);
|
||||
const [domains, setDomains] = useState((initial.allowed_email_domains || []).join(', '));
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const group_role_map: Record<string, UserRole> = {};
|
||||
for (const r of groupRows) if (r.group.trim()) group_role_map[r.group.trim()] = r.role;
|
||||
const allowed_email_domains = domains.split(',').map((d) => d.trim()).filter(Boolean);
|
||||
const payload: Partial<OidcConfigView> & { client_secret?: string } = {
|
||||
...form!,
|
||||
group_role_map,
|
||||
allowed_email_domains,
|
||||
};
|
||||
delete (payload as Record<string, unknown>).redirect_uri;
|
||||
delete (payload as Record<string, unknown>).has_secret;
|
||||
if (secret) payload.client_secret = secret;
|
||||
return oidcApi.updateConfig(payload);
|
||||
},
|
||||
onSuccess: (r) => {
|
||||
setSecret('');
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
qc.setQueryData(['oidc-config'], r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => oidcApi.test(form.issuer_url),
|
||||
onSuccess: (r) => setTestResult({ ok: r.data.ok, msg: r.data.ok ? `Discovered ${r.data.issuer}` : (r.data.error || 'Failed') }),
|
||||
onError: (e: unknown) => setTestResult({ ok: false, msg: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Discovery failed' }),
|
||||
});
|
||||
|
||||
const set = <K extends keyof OidcConfigView>(k: K, v: OidcConfigView[K]) => setForm({ ...form, [k]: v });
|
||||
|
||||
const copyRedirect = () => {
|
||||
navigator.clipboard.writeText(form.redirect_uri).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Single Sign-On (OIDC)</h3>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">authenticate against your identity provider</span>
|
||||
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Redirect URI to register with the IdP */}
|
||||
<div>
|
||||
<label className="label">Redirect URI (register this with your provider)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2 py-1.5 rounded bg-gray-50 dark:bg-slate-800 font-mono text-xs break-all text-gray-700 dark:text-slate-300">{form.redirect_uri}</code>
|
||||
<button className="btn-secondary flex items-center gap-1.5" onClick={copyRedirect}>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider connection */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">Issuer URL</label>
|
||||
<input className="input w-full" value={form.issuer_url} placeholder="https://login.example.com/realms/main" onChange={(e) => set('issuer_url', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Client ID</label>
|
||||
<input className="input w-full" value={form.client_id} onChange={(e) => set('client_id', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Client Secret {form.has_secret && <span className="text-green-600 dark:text-green-400">(configured)</span>}</label>
|
||||
<input className="input w-full" type="password" value={secret} placeholder={form.has_secret ? '•••••••• (leave blank to keep)' : 'none — public client'} onChange={(e) => setSecret(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Scopes</label>
|
||||
<input className="input w-full" value={form.scopes} onChange={(e) => set('scopes', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Button label</label>
|
||||
<input className="input w-full" value={form.button_label} onChange={(e) => set('button_label', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-secondary flex items-center gap-1.5" disabled={!form.issuer_url || test.isPending} onClick={() => test.mutate()}>
|
||||
<RefreshCw className={test.isPending ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />Test discovery
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${testResult.ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{testResult.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}{testResult.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim mapping */}
|
||||
<div className="grid gap-3 sm:grid-cols-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
|
||||
<div>
|
||||
<label className="label">Username claim</label>
|
||||
<input className="input w-full" value={form.username_claim} onChange={(e) => set('username_claim', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Email claim</label>
|
||||
<input className="input w-full" value={form.email_claim} onChange={(e) => set('email_claim', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Groups claim</label>
|
||||
<input className="input w-full" value={form.groups_claim} onChange={(e) => set('groups_claim', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group → role mapping */}
|
||||
<div>
|
||||
<label className="label">Group → role mapping</label>
|
||||
<div className="space-y-2">
|
||||
{groupRows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input className="input flex-1" placeholder="IdP group name" value={row.group} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, group: e.target.value } : r))} />
|
||||
<span className="text-gray-400">→</span>
|
||||
<select className="input w-36" value={row.role} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, role: e.target.value as UserRole } : r))}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button className="p-1.5 text-gray-400 hover:text-red-600" onClick={() => setGroupRows(groupRows.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-secondary flex items-center gap-1.5" onClick={() => setGroupRows([...groupRows, { group: '', role: 'viewer' }])}>
|
||||
<Plus className="w-4 h-4" />Add mapping
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="label mb-0">Default role (no group match)</label>
|
||||
<select className="input w-36" value={form.default_role} onChange={(e) => set('default_role', e.target.value as UserRole)}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provisioning */}
|
||||
<div className="grid gap-2 pt-2 border-t border-gray-100 dark:border-slate-700/50">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={form.auto_provision} onChange={(e) => set('auto_provision', e.target.checked)} />
|
||||
Auto-provision new users on first sign-in
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={form.link_by_verified_email} onChange={(e) => set('link_by_verified_email', e.target.checked)} />
|
||||
Link to existing accounts by verified email
|
||||
</label>
|
||||
<div>
|
||||
<label className="label">Allowed email domains (comma-separated, blank = any)</label>
|
||||
<input className="input w-full" value={domains} placeholder="example.com, corp.example.com" onChange={(e) => setDomains(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Public base URL (optional — overrides the redirect URI host)</label>
|
||||
<input className="input w-full" value={form.public_base_url} placeholder="https://manager.example.com" onChange={(e) => set('public_base_url', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
|
||||
<button className="btn-primary flex items-center gap-1.5" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{save.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}Save
|
||||
</button>
|
||||
{saved && <span className="text-xs text-green-600 dark:text-green-400">Saved</span>}
|
||||
{save.isError && <span className="text-xs text-red-600 dark:text-red-400">{(save.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Save failed'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, FormEvent, useRef } from 'react';
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Network, Eye, EyeOff, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import { Network, Eye, EyeOff, AlertCircle, ShieldCheck, LogIn } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -16,10 +16,22 @@ export default function LoginPage() {
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [sso, setSso] = useState<{ enabled: boolean; button_label: string }>({ enabled: false, button_label: 'Sign in with SSO' });
|
||||
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
|
||||
useEffect(() => {
|
||||
authApi.oidcStatus().then((r) => setSso(r.data)).catch(() => {});
|
||||
// Surface an SSO error passed back on the redirect (?error=sso&reason=...).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('error') === 'sso') {
|
||||
setError(params.get('reason') || 'Single sign-on failed. Please try again.');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -234,6 +246,28 @@ export default function LoginPage() {
|
||||
totpToken ? 'Verify Code' : 'Sign in'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sso.enabled && !totpToken && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-1">
|
||||
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>or</span>
|
||||
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-2 font-medium rounded-lg transition-colors border ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border-slate-600 text-white hover:bg-slate-700'
|
||||
: 'bg-white/80 border-slate-300 text-slate-800 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{sso.button_label || 'Sign in with SSO'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { User, UserRole } from '../types';
|
||||
|
||||
/**
|
||||
* Landing route for the OIDC redirect. The backend hands us the session JWT in
|
||||
* the URL fragment (never sent to the server); we hydrate the auth store and go
|
||||
* to the dashboard.
|
||||
*/
|
||||
export default function OidcCallbackPage() {
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
const ran = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ran.current) return;
|
||||
ran.current = true;
|
||||
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const token = hash.get('token');
|
||||
// Same-site relative paths only (reject absolute / protocol-relative).
|
||||
const rawReturn = hash.get('returnTo') || '/dashboard';
|
||||
const returnTo = /^\/(?!\/)[^\\]*$/.test(rawReturn) ? rawReturn : '/dashboard';
|
||||
|
||||
if (!token) {
|
||||
navigate('/login?error=sso&reason=' + encodeURIComponent('No token returned'), { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the token first so the /me request is authenticated.
|
||||
setAuth(token, { id: 0, username: '', role: 'viewer' });
|
||||
authApi.me()
|
||||
.then((r) => {
|
||||
const u = (r.data as { user: { userId?: number; id?: number; username: string; role: UserRole } }).user;
|
||||
const user: User = { id: u.id ?? u.userId ?? 0, username: u.username, role: u.role };
|
||||
setAuth(token, user);
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/dashboard', { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
useAuthStore.getState().logout();
|
||||
navigate('/login?error=sso&reason=' + encodeURIComponent('Could not load your account'), { replace: true });
|
||||
});
|
||||
}, [navigate, setAuth]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<span className="inline-block w-5 h-5 border-2 border-slate-400/30 border-t-slate-400 rounded-full animate-spin" />
|
||||
Signing you in…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn,
|
||||
} from 'lucide-react';
|
||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
||||
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import clsx from 'clsx';
|
||||
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
||||
import AutomationSettings from '../components/settings/AutomationSettings';
|
||||
import OidcSettings from '../components/settings/OidcSettings';
|
||||
|
||||
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
|
||||
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
|
||||
@@ -48,7 +49,7 @@ export default function SettingsPage() {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const canWrite = user?.role !== 'viewer';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
|
||||
const [auditSearch, setAuditSearch] = useState('');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
@@ -472,6 +473,7 @@ export default function SettingsPage() {
|
||||
tabs: [
|
||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||
...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []),
|
||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||
],
|
||||
@@ -872,9 +874,17 @@ export default function SettingsPage() {
|
||||
{(users as User[]).map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{u.username}
|
||||
{u.id === user?.id && (
|
||||
<span className="ml-2 text-xs text-blue-500">(you)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{u.username}
|
||||
{u.id === user?.id && (
|
||||
<span className="text-xs text-blue-500">(you)</span>
|
||||
)}
|
||||
{u.auth_provider === 'oidc' && (
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">SSO</span>
|
||||
)}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-normal">{u.email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
@@ -1806,6 +1816,9 @@ export default function SettingsPage() {
|
||||
{activeTab === 'automation' && (
|
||||
<AutomationSettings isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeTab === 'sso' && (
|
||||
<OidcSettings isAdmin={isAdmin} />
|
||||
)}
|
||||
{/* ── Config Templates ── */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
ConfigSnapshotFull,
|
||||
ConfigDiffResponse,
|
||||
ConfigCaptureResult,
|
||||
UserRole,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
@@ -66,6 +67,38 @@ export const authApi = {
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
securityStatus: () =>
|
||||
api.get<{ warnings: string[] }>('/auth/security-status'),
|
||||
oidcStatus: () =>
|
||||
api.get<{ enabled: boolean; button_label: string }>('/auth/oidc/status'),
|
||||
};
|
||||
|
||||
export interface OidcConfigView {
|
||||
enabled: boolean;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
has_secret: boolean;
|
||||
scopes: string;
|
||||
username_claim: string;
|
||||
email_claim: string;
|
||||
groups_claim: string;
|
||||
group_role_map: Record<string, UserRole>;
|
||||
default_role: UserRole;
|
||||
auto_provision: boolean;
|
||||
link_by_verified_email: boolean;
|
||||
allowed_email_domains: string[];
|
||||
button_label: string;
|
||||
public_base_url: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export const oidcApi = {
|
||||
getConfig: () => api.get<OidcConfigView>('/auth/oidc/config'),
|
||||
updateConfig: (data: Partial<OidcConfigView> & { client_secret?: string }) =>
|
||||
api.put<OidcConfigView>('/auth/oidc/config', data),
|
||||
test: (issuer_url?: string) =>
|
||||
api.post<{ ok: boolean; issuer?: string; claims_supported?: string[]; error?: string }>(
|
||||
'/auth/oidc/test',
|
||||
{ issuer_url }
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,9 @@ export type UserRole = 'admin' | 'operator' | 'viewer';
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
role: UserRole;
|
||||
auth_provider?: 'local' | 'oidc';
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.16.12 Beta';
|
||||
export const APP_VERSION = 'v0.17.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user