diff --git a/ai-hub/app/api/dependencies.py b/ai-hub/app/api/dependencies.py
index 783f8f2..20d3ccc 100644
--- a/ai-hub/app/api/dependencies.py
+++ b/ai-hub/app/api/dependencies.py
@@ -34,6 +34,14 @@
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
+
+ from app.config import settings
+ if not user.password_hash and not settings.OIDC_ENABLED:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Account disabled: OIDC is inactive and no password is set."
+ )
+
return user
diff --git a/ai-hub/app/api/routes/admin.py b/ai-hub/app/api/routes/admin.py
index 1ed915c..b556f57 100644
--- a/ai-hub/app/api/routes/admin.py
+++ b/ai-hub/app/api/routes/admin.py
@@ -11,6 +11,18 @@
update: schemas.OIDCConfigUpdate,
admin = Depends(get_current_admin)
):
+ checking_disabled = False
+ if update.enabled is not None:
+ checking_disabled = not update.enabled
+ if update.allow_oidc_login is not None:
+ checking_disabled = not update.allow_oidc_login
+
+ if checking_disabled:
+ if not settings.ALLOW_PASSWORD_LOGIN:
+ raise HTTPException(status_code=400, detail="Cannot disable OIDC login while password login is also disabled.")
+ if not admin.password_hash:
+ raise HTTPException(status_code=400, detail="SafeGuard: Cannot disable OIDC! You do not have a local password set, which would lock you out of your Admin account.")
+
if update.enabled is not None:
settings.OIDC_ENABLED = update.enabled
if update.client_id is not None:
@@ -22,8 +34,6 @@
if update.redirect_uri is not None:
settings.OIDC_REDIRECT_URI = update.redirect_uri
if update.allow_oidc_login is not None:
- if not update.allow_oidc_login and not settings.ALLOW_PASSWORD_LOGIN:
- raise HTTPException(status_code=400, detail="Cannot disable OIDC login while password login is also disabled.")
settings.ALLOW_OIDC_LOGIN = update.allow_oidc_login
settings.save_to_yaml()
@@ -35,7 +45,8 @@
admin = Depends(get_current_admin)
):
if update.allow_password_login is not None:
- if not update.allow_password_login and not settings.ALLOW_OIDC_LOGIN:
+ is_oidc_active = settings.ALLOW_OIDC_LOGIN or settings.OIDC_ENABLED
+ if not update.allow_password_login and not is_oidc_active:
raise HTTPException(status_code=400, detail="Cannot disable password login while OIDC login is also disabled.")
settings.ALLOW_PASSWORD_LOGIN = update.allow_password_login
@@ -62,6 +73,10 @@
except Exception as e:
return {"success": False, "message": f"Failed to reach OIDC provider: {str(e)}"}
+ @router.get("/config/swarm/test/{nonce}", summary="Echo Swarm Nonce")
+ async def echo_swarm_nonce(nonce: str):
+ return {"nonce": nonce}
+
@router.post("/config/swarm/test", summary="Test Swarm Connection")
async def test_swarm_connection(
update: schemas.SwarmConfigUpdate,
@@ -71,16 +86,28 @@
raise HTTPException(status_code=400, detail="External endpoint is required for testing.")
import httpx
+ import uuid
try:
- # We try to reach the endpoint. Since it's gRPC, we might just do a TCP check
- # or a basic GET if it's behind a proxy that handles health checks.
- # For simplicity, we'll check if the protocol is valid and we can reach it.
- async with httpx.AsyncClient() as client:
- # Most swarm proxies will have a /health or just return 404/405 for GET on root
- response = await client.get(update.external_endpoint, timeout=5.0)
- return {"success": True, "message": f"Reached endpoint with status {response.status_code}"}
+ nonce = str(uuid.uuid4())
+ test_url = f"{update.external_endpoint.rstrip('/')}/api/v1/admin/config/swarm/test/{nonce}"
+
+ async with httpx.AsyncClient(verify=False) as client:
+ response = await client.get(test_url, timeout=10.0)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("nonce") == nonce:
+ return {"success": True, "message": "Successfully routed back to this hub instance!"}
+ else:
+ return {"success": False, "message": "Connected to an endpoint, but the verification signature did not match."}
+ else:
+ return {"success": False, "message": f"Endpoint reachable, but returned status {response.status_code}."}
+ except httpx.ConnectError:
+ return {"success": False, "message": "Failed to connect: Connection refused. Check if the domain/IP is correct and listening."}
+ except httpx.TimeoutException:
+ return {"success": False, "message": "Connection timed out. Check firewall or proxy settings."}
except Exception as e:
- return {"success": False, "message": f"Failed to connect: {str(e)}"}
+ return {"success": False, "message": f"Verification failed: {str(e)}"}
@router.put("/config/swarm", summary="Update Swarm Configuration")
async def update_swarm_config(
diff --git a/ai-hub/app/api/routes/user.py b/ai-hub/app/api/routes/user.py
index 87dd010..f5c6acb 100644
--- a/ai-hub/app/api/routes/user.py
+++ b/ai-hub/app/api/routes/user.py
@@ -83,10 +83,11 @@
Requires a valid user_id to be present in the request header.
"""
try:
- # In a real-world scenario, you would fetch user details from the DB using user_id
- # For this example, we return a mock response based on the presence of user_id
+ user : Optional[models.User] = services.user_service.get_user_by_id(db=db, user_id=user_id)
- user : Optional[models.User] = services.user_service.get_user_by_id(db=db, user_id=user_id) # Ensure user exists
+ if user and not user.password_hash and not settings.OIDC_ENABLED:
+ raise HTTPException(status_code=403, detail="Account disabled: OIDC is inactive and no password is set.")
+
email = user.email if user else None
is_anonymous = user is None
is_logged_in = user is not None
@@ -95,8 +96,11 @@
email=email,
is_logged_in=is_logged_in,
is_anonymous=is_anonymous,
- oidc_configured=settings.OIDC_ENABLED
+ oidc_configured=settings.OIDC_ENABLED,
+ allow_password_login=settings.ALLOW_PASSWORD_LOGIN
)
+ except HTTPException as he:
+ raise he
except Exception as e:
raise HTTPException(status_code=500, detail=f"An error occurred: {e}")
diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py
index d0127ed..6a8f10c 100644
--- a/ai-hub/app/api/schemas.py
+++ b/ai-hub/app/api/schemas.py
@@ -25,6 +25,7 @@
is_logged_in: bool = Field(True, description="Indicates if the user is currently authenticated.")
is_anonymous: bool = Field(False, description="Indicates if the user is an anonymous user.")
oidc_configured: bool = Field(False, description="Whether OIDC SSO is enabled on the server.")
+ allow_password_login: bool = Field(True, description="Whether local password login is supported by the server.")
class UserProfile(BaseModel):
id: str
diff --git a/deployment/jerxie-prod/docker-compose.production.yml b/deployment/jerxie-prod/docker-compose.production.yml
index 64d1040..ed3330c 100644
--- a/deployment/jerxie-prod/docker-compose.production.yml
+++ b/deployment/jerxie-prod/docker-compose.production.yml
@@ -8,6 +8,8 @@
environment:
- HUB_PUBLIC_URL=https://ai.jerxie.com
- HUB_GRPC_ENDPOINT=ai.jerxie.com:443
+ - OIDC_ENABLED=true
+ - ALLOW_PASSWORD_LOGIN=false
- OIDC_CLIENT_ID=cortex-server
- OIDC_CLIENT_SECRET=aYc2j1lYUUZXkBFFUndnleZI
- OIDC_SERVER_URL=https://auth.jerxie.com
diff --git a/frontend/src/features/auth/pages/LoginPage.js b/frontend/src/features/auth/pages/LoginPage.js
index f8ff809..f79f316 100644
--- a/frontend/src/features/auth/pages/LoginPage.js
+++ b/frontend/src/features/auth/pages/LoginPage.js
@@ -6,6 +6,7 @@
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [oidcEnabled, setOidcEnabled] = useState(false);
+ const [passwordEnabled, setPasswordEnabled] = useState(true);
// Local login state
const [email, setEmail] = useState('');
@@ -18,6 +19,9 @@
try {
const config = await getAuthConfig();
setOidcEnabled(config.oidc_configured);
+ if (config.allow_password_login !== undefined) {
+ setPasswordEnabled(config.allow_password_login);
+ }
} catch (err) {
console.error("Failed to fetch auth config", err);
}
@@ -147,59 +151,60 @@
)}
-
+ {passwordEnabled && (
+
+ )}
{oidcEnabled && (
-
+ {passwordEnabled && (
+
+ )}
-
-
-
-
-
+
+
Sign in with SSO
diff --git a/frontend/src/features/profile/pages/ProfilePage.js b/frontend/src/features/profile/pages/ProfilePage.js
index 00b708d..b0b4d46 100644
--- a/frontend/src/features/profile/pages/ProfilePage.js
+++ b/frontend/src/features/profile/pages/ProfilePage.js
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import {
getUserProfile, updateUserProfile, getUserConfig, updateUserConfig,
- getUserAccessibleNodes, getUserNodePreferences, updateUserNodePreferences
+ getUserAccessibleNodes, getUserNodePreferences, updateUserNodePreferences,
+ updatePassword
} from '../../../services/apiService';
const ProfilePage = ({ onLogout }) => {
@@ -12,6 +13,7 @@
const [nodePrefs, setNodePrefs] = useState({ default_node_ids: [], data_source: { source: 'empty', path: '' } });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
+ const [pwdSaving, setPwdSaving] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [providerStatuses, setProviderStatuses] = useState({});
const [editData, setEditData] = useState({
@@ -19,6 +21,7 @@
username: '',
avatar_url: ''
});
+ const [passwordData, setPasswordData] = useState({ current: '', new: '' });
useEffect(() => {
loadData();
@@ -71,6 +74,22 @@
}
};
+ const handlePasswordSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ setPwdSaving(true);
+ await updatePassword(passwordData.current, passwordData.new);
+ setPasswordData({ current: '', new: '' });
+ setMessage({ type: 'success', text: 'Password set successfully!' });
+ setTimeout(() => setMessage({ type: '', text: '' }), 5000);
+ } catch (err) {
+ setMessage({ type: 'error', text: err.message || 'Failed to update password.' });
+ setTimeout(() => setMessage({ type: '', text: '' }), 5000);
+ } finally {
+ setPwdSaving(false);
+ }
+ };
+
const handlePreferenceChange = async (section, providerId) => {
try {
const newConfig = {
@@ -147,14 +166,14 @@
{profile.full_name || profile.username || 'Citizen'}
-
{profile.email}
-
Member since {new Date(profile.created_at).toLocaleDateString()}
+
{profile.email}
+
Member since {new Date(profile.created_at).toLocaleDateString()}
-
+
{profile.role}
{profile.group_name && (
-
+
{profile.group_name} Group
)}
@@ -163,7 +182,7 @@
{onLogout && (
@@ -181,48 +200,92 @@
)}
- {/* General Information */}
-
-
-
- General Information
-
-
+
+ {/* General Information */}
+
+
+
+ General Information
+
+
+
+
+ {/* Account Security */}
+
+
+
+
+
+ Account Security
+
+
+
{/* Service Preferences */}
@@ -258,66 +321,7 @@
/>
- {/* Voice Experience Section */}
-
-
-
-
-
- Voice Chat Experience
-
-
-
-
-
AI Voice Identity
-
setConfig({...config, voice_voice: e.target.value})}
- onBlur={() => handleGeneralPreferenceUpdate({ voice_voice: config.voice_voice })}
- className={inputClass}
- placeholder="e.g. onyx, alloy, shimmer"
- />
-
The specific voice profile used by your TTS engine.
-
-
-
-
-
-
Continuous Auto-Listening
-
Automatically reactivates microphone after AI finishes speaking.
-
-
{
- const newVal = !config?.voice_auto_listen;
- setConfig({...config, voice_auto_listen: newVal});
- handleGeneralPreferenceUpdate({ voice_auto_listen: newVal });
- }}
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${config?.voice_auto_listen ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700'}`}
- >
-
-
-
-
-
-
Silence Sensitivity ({Math.round((config?.voice_sensitivity || 0.5) * 100)}%)
-
setConfig({...config, voice_sensitivity: parseFloat(e.target.value)})}
- onMouseUp={() => handleGeneralPreferenceUpdate({ voice_sensitivity: config.voice_sensitivity })}
- className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
-
- Aggressive
- Balanced
- Relaxed
-
-
-
-
{/* Node Defaults Section */}
@@ -330,7 +334,7 @@
{accessibleNodes.length > 0 ? (
-
Auto-attach these nodes to new sessions:
+
Auto-attach these nodes to new sessions:
{accessibleNodes.map(node => {
const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id);
@@ -339,7 +343,7 @@
key={node.node_id}
type="button"
onClick={() => toggleDefaultNode(node.node_id)}
- className={`px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border-2 transition-all ${isActive
+ className={`px-4 py-2 rounded-xl text-[10px] font-black border-2 transition-all ${isActive
? 'bg-indigo-600 border-indigo-600 text-white shadow-lg shadow-indigo-200 dark:shadow-none translate-y-[-1px]'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 hover:border-indigo-300'
}`}
@@ -386,7 +390,7 @@
-
Status:
+
Status:
Verified
@@ -439,7 +443,7 @@
modelDisplay && `Model: ${modelDisplay}`,
voiceDisplay && `Voice: ${voiceDisplay}`
].filter(Boolean).join(' · ') || undefined}
- className={`group flex flex-col items-start gap-0.5 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest border-2 transition-all ${isActive
+ className={`group flex flex-col items-start gap-0.5 px-4 py-2 rounded-xl text-xs font-black border-2 transition-all ${isActive
? 'bg-indigo-600 border-indigo-600 text-white shadow-lg shadow-indigo-200 dark:shadow-none translate-y-[-2px]'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-indigo-300 dark:hover:border-indigo-600'
}`}
diff --git a/frontend/src/features/settings/components/SettingsPageContent.js b/frontend/src/features/settings/components/SettingsPageContent.js
index 1a8f990..f59987e 100644
--- a/frontend/src/features/settings/components/SettingsPageContent.js
+++ b/frontend/src/features/settings/components/SettingsPageContent.js
@@ -20,7 +20,7 @@
-
+
Settings & Governance
@@ -32,22 +32,22 @@
-
+
Export
fileInputRef.current?.click()}
- className="px-4 py-2.5 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-xl text-[10px] font-black uppercase tracking-widest hover:border-emerald-300 dark:hover:border-emerald-700 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all flex items-center gap-2 shadow-sm whitespace-nowrap"
+ className="px-4 py-2.5 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-xl text-[10px] font-black hover:border-emerald-300 dark:hover:border-emerald-700 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all flex items-center gap-2 shadow-sm whitespace-nowrap"
title="Import System YAML"
>
-
+
Import
@@ -97,20 +97,20 @@
⚠️
-
Final Confirmation
+
Final Confirmation
{confirmAction.label}
setConfirmAction(null)}
- className="flex-1 py-4 text-xs font-black uppercase tracking-widest text-gray-500 hover:text-gray-700 dark:hover:text-white transition-colors"
+ className="flex-1 py-4 text-xs font-black text-gray-500 hover:text-gray-700 dark:hover:text-white transition-colors"
>
Cancel
Confirm
diff --git a/frontend/src/features/settings/components/cards/AIConfigurationCard.js b/frontend/src/features/settings/components/cards/AIConfigurationCard.js
index 348fd1d..1881a33 100644
--- a/frontend/src/features/settings/components/cards/AIConfigurationCard.js
+++ b/frontend/src/features/settings/components/cards/AIConfigurationCard.js
@@ -38,8 +38,8 @@
-
{title} Resources
-
{description}
+
{title} Resources
+
{description}
@@ -120,7 +120,7 @@
handleAddInstance(sectionKey)}
disabled={!addForm.type}
- className="px-8 py-2 bg-indigo-600 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:bg-indigo-700 disabled:opacity-50 transition-all"
+ className="px-8 py-2 bg-indigo-600 text-white rounded-xl text-xs font-black shadow-lg hover:bg-indigo-700 disabled:opacity-50 transition-all"
>
Create Instance
@@ -134,7 +134,7 @@
-
Add New {title} Provider
+
Add New {title} Provider
)}
@@ -153,10 +153,10 @@
-
+
AI Resource Management
- Global AI providers, model endpoints, and synthesis engines
+
Global AI providers, model endpoints, and synthesis engines
@@ -175,7 +175,7 @@
key={tab.id}
type="button"
onClick={() => setActiveConfigTab(tab.id)}
- className={`flex-1 min-w-[120px] py-4 text-[10px] font-black uppercase tracking-widest transition-all duration-200 focus:outline-none ${activeConfigTab === tab.id
+ className={`flex-1 min-w-[120px] py-4 text-[10px] font-black transition-all duration-200 focus:outline-none ${activeConfigTab === tab.id
? 'text-emerald-600 dark:text-emerald-400 border-b-2 border-emerald-600 dark:border-emerald-400 bg-white dark:bg-gray-800 shadow-sm z-10'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-700/30'
}`}
@@ -205,7 +205,7 @@
{saving ? 'Saving...' : 'Save AI Configuration'}
diff --git a/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js b/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js
index 18269c6..0cc9b1c 100644
--- a/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js
+++ b/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js
@@ -43,10 +43,10 @@
-
+
Identity & Access Governance
- Manage user groups, resource whitelists, and individual account policies
+
Manage user groups, resource whitelists, and individual account policies
@@ -64,7 +64,7 @@
key={tab.id}
type="button"
onClick={() => setActiveAdminTab(tab.id)}
- className={`flex-1 min-w-[120px] py-4 text-[10px] font-black uppercase tracking-widest transition-all duration-200 focus:outline-none ${activeAdminTab === tab.id
+ className={`flex-1 min-w-[120px] py-4 text-[10px] font-black transition-all duration-200 focus:outline-none ${activeAdminTab === tab.id
? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800 shadow-sm z-10'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-700/30'
}`}
@@ -82,15 +82,15 @@
-
Governed Policy Groups
-
Define resource whitelists for teams and users
+
Governed Policy Groups
+
Define resource whitelists for teams and users
{
const newPolicy = { llm: [], tts: [], stt: [], nodes: [], skills: [] };
setEditingGroup({ id: 'new', name: '', description: '', policy: newPolicy });
}}
- className="px-6 py-2.5 bg-indigo-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-indigo-200 dark:shadow-none hover:bg-indigo-700 transition-all flex items-center gap-2"
+ className="px-6 py-2.5 bg-indigo-600 text-white rounded-xl text-[10px] font-black shadow-lg shadow-indigo-200 dark:shadow-none hover:bg-indigo-700 transition-all flex items-center gap-2"
>
Create Group
@@ -106,8 +106,8 @@
-
{g.id === 'ungrouped' ? 'Ungrouped (Default)' : g.name}
- {g.id === 'ungrouped' && System }
+ {g.id === 'ungrouped' ? 'Ungrouped (Default)' : g.name}
+ {g.id === 'ungrouped' && System }
{g.description || 'No description provided.'}
@@ -165,7 +165,7 @@
{editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`}
{editingGroup.id === 'ungrouped' && (
-
+
System Group
@@ -214,13 +214,13 @@
-
Provider Access Policy (Whitelists)
+
Provider Access Policy (Whitelists)
{['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => (
-
{section === 'nodes' ? 'Accessible Nodes' : `${section} Access`}
+
{section === 'nodes' ? 'Accessible Nodes' : `${section} Access`}
{
let availableIds = [];
@@ -271,7 +271,7 @@
setEditingGroup(null)} className="px-6 py-2 rounded-xl text-sm font-bold text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">Cancel
- g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase())} className="px-8 py-2 bg-indigo-600 text-white rounded-xl text-sm font-black uppercase tracking-widest shadow-lg shadow-indigo-200 dark:shadow-indigo-900/50 hover:bg-indigo-700 disabled:opacity-50 transition-all">
+ g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase())} className="px-8 py-2 bg-indigo-600 text-white rounded-xl text-sm font-black shadow-lg shadow-indigo-200 dark:shadow-indigo-900/50 hover:bg-indigo-700 disabled:opacity-50 transition-all">
{saving ? 'Saving...' : 'Save Group'}
@@ -311,10 +311,10 @@
- Member
- Policy Group
- Activity Auditing
- Actions
+ Member
+ Policy Group
+ Activity Auditing
+ Actions
@@ -327,7 +327,7 @@
{u.username || u.email}
-
{u.role}
+
{u.role}
@@ -347,11 +347,11 @@
- Join:
+ Join:
{new Date(u.created_at).toLocaleDateString()}
-
Last:
+
Last:
{u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}
@@ -362,7 +362,7 @@
{ e.preventDefault(); handleRoleToggle(u); }}
- className={`text-[9px] font-black uppercase tracking-widest transition-all ${u.role === 'admin'
+ className={`text-[9px] font-black transition-all ${u.role === 'admin'
? 'text-red-600 hover:text-red-700'
: 'text-indigo-600 hover:text-indigo-700'
}`}
diff --git a/frontend/src/features/settings/components/cards/NetworkIdentityCard.js b/frontend/src/features/settings/components/cards/NetworkIdentityCard.js
index 31908cf..ec88dd8 100644
--- a/frontend/src/features/settings/components/cards/NetworkIdentityCard.js
+++ b/frontend/src/features/settings/components/cards/NetworkIdentityCard.js
@@ -1,4 +1,5 @@
-import React from 'react';
+import React, { useState } from 'react';
+import { testAdminOIDCConfig, testAdminSwarmConfig } from '../../../../services/apiService';
const NetworkIdentityCard = ({ context }) => {
const {
@@ -8,13 +9,44 @@
setAdminConfig,
handleSaveAdminConfig,
fileInputRef,
- handleTestConnection,
- testingConnection,
userProfile,
labelClass,
inputClass
} = context;
+ const [localTestStatus, setLocalTestStatus] = useState({ oidc: null, swarm: null });
+ const [isTesting, setIsTesting] = useState({ oidc: false, swarm: false });
+ const [lastTestedConfig, setLastTestedConfig] = useState({ oidc: null, swarm: null });
+
+ const doTest = async (type) => {
+ setIsTesting(prev => ({...prev, [type]: true}));
+ setLocalTestStatus(prev => ({...prev, [type]: null}));
+
+ let response;
+ try {
+ if (type === 'oidc') {
+ response = await testAdminOIDCConfig(adminConfig.oidc);
+ } else {
+ response = await testAdminSwarmConfig(adminConfig.swarm);
+ }
+
+ setLocalTestStatus(prev => ({...prev, [type]: response}));
+ if (response?.success) {
+ setLastTestedConfig(prev => ({...prev, [type]: JSON.stringify(adminConfig[type]) }));
+ } else {
+ setLastTestedConfig(prev => ({...prev, [type]: null }));
+ }
+ } catch (err) {
+ setLocalTestStatus(prev => ({...prev, [type]: { success: false, message: err.message || 'Connection test failed' }}));
+ setLastTestedConfig(prev => ({...prev, [type]: null }));
+ } finally {
+ setIsTesting(prev => ({...prev, [type]: false}));
+ }
+ };
+
+ const isOidcValid = !adminConfig.oidc?.server_url || lastTestedConfig.oidc === JSON.stringify(adminConfig.oidc);
+ const isSwarmValid = !adminConfig.swarm?.external_endpoint || lastTestedConfig.swarm === JSON.stringify(adminConfig.swarm);
+
if (userProfile?.role !== 'admin') return null;
return (
@@ -26,10 +58,10 @@
-
+
Network Access & External Identity
- Manage authentication protocols, SSO, and swarm deployment endpoints
+
Manage authentication protocols, SSO, and swarm deployment endpoints
@@ -48,22 +80,22 @@
Access Security Model
-
- Mutual Exclusivity: Enabling OIDC automatically disables local password login to ensure enterprise SSO compliance.
+
+ Access Policy: You may enable OIDC, Local Password, or both. For safety, at least one authentication method must remain active.
handleSaveAdminConfig('app', { ...(adminConfig.app || {}), allow_password_login: !adminConfig.app?.allow_password_login })}
- className={`w-full px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all ${adminConfig.app?.allow_password_login ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20' : 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'}`}
+ className={`w-full px-6 py-3 rounded-2xl text-[10px] font-black transition-all ${adminConfig.app?.allow_password_login ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20' : 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'}`}
>
{adminConfig.app?.allow_password_login ? 'Local Password Enabled' : 'Local Password Prohibited'}
handleSaveAdminConfig('oidc', { ...(adminConfig.oidc || {}), enabled: !adminConfig.oidc?.enabled })}
- className={`w-full px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all ${adminConfig.oidc?.enabled ? 'bg-emerald-600 text-white shadow-lg shadow-emerald-500/20' : 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'}`}
+ className={`w-full px-6 py-3 rounded-2xl text-[10px] font-black transition-all ${adminConfig.oidc?.enabled ? 'bg-emerald-600 text-white shadow-lg shadow-emerald-500/20' : 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'}`}
>
{adminConfig.oidc?.enabled ? 'OIDC/SSO Integration Active' : 'OIDC/SSO Disabled'}
@@ -72,32 +104,37 @@
{/* OIDC Details */}
-
+
-
+
OIDC Configuration Details
- Enterprise Social Login & Identity Synchronization
+
Enterprise Social Login & Identity Synchronization
handleTestConnection('oidc')}
- className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-all border border-indigo-100 dark:border-indigo-800"
+ disabled={isTesting.oidc}
+ onClick={() => doTest('oidc')}
+ className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-xl text-[10px] font-black hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-all border border-indigo-100 dark:border-indigo-800"
>
- {testingConnection === 'oidc' ? 'Verifying...' : 'Test Connection'}
+ {isTesting.oidc ? 'Verifying...' : 'Test Connection'}
- {/* Local Connection Feedback */}
- {testingConnection === 'oidc' && (
-
+ {/* OIDC Test Message Inline Feedback */}
+ {isTesting.oidc && (
+
Attempting to discover OIDC provider configuration...
)}
+ {localTestStatus.oidc && (
+
+ {localTestStatus.oidc.success ? '✅ ' : '❌ '} {localTestStatus.oidc.message}
+
+ )}
@@ -152,44 +189,54 @@
autoComplete="off"
/>
-
+
+ {!isOidcValid && adminConfig.oidc?.server_url && (
+ ⚠️ Test required before saving!
+ )}
handleSaveAdminConfig('oidc', adminConfig.oidc)}
- className="px-6 py-2 bg-indigo-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-indigo-700 transition-all font-sans"
+ className={`px-6 py-2 rounded-xl text-[10px] font-black shadow-md transition-all font-sans ${isOidcValid ? 'bg-indigo-600 text-white hover:bg-indigo-700' : 'bg-gray-300 text-gray-500 cursor-not-allowed opacity-60'}`}
>
- Save OIDC Settings
+ Update OIDC Identity
- {/* Swarm Details */}
-
+ {/* Swarm Endpoint Details */}
+
-
+
Swarm Access Configuration
- Infrastructure gRPC Connectivity & Discovery
+
Infrastructure gRPC Connectivity & Discovery
-
+
handleTestConnection('swarm')}
- className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-all border border-indigo-100 dark:border-indigo-800"
+ disabled={isTesting.swarm}
+ onClick={() => doTest('swarm')}
+ className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-xl text-[10px] font-black hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-all border border-indigo-100 dark:border-indigo-800"
>
- {testingConnection === 'swarm' ? 'Verifying...' : 'Test Connection'}
+ {isTesting.swarm ? 'Verifying...' : 'Test Connection'}
- {testingConnection === 'swarm' && (
-
+ {/* Swarm Test Message Inline Feedback */}
+ {isTesting.swarm && (
+
Pinging Swarm external endpoint...
)}
+ {localTestStatus.swarm && (
+
+ {localTestStatus.swarm.success ? '✅ ' : '❌ '} {localTestStatus.swarm.message}
+
+ )}
@@ -206,11 +253,15 @@
autoComplete="off"
/>
-
+
+ {!isSwarmValid && adminConfig.swarm?.external_endpoint && (
+ ⚠️ Test required before saving!
+ )}
handleSaveAdminConfig('swarm', adminConfig.swarm)}
- className="px-6 py-2 bg-indigo-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-indigo-700 transition-all font-sans"
+ className={`px-6 py-2 rounded-xl text-[10px] font-black shadow-md transition-all font-sans ${isSwarmValid ? 'bg-indigo-600 text-white hover:bg-indigo-700' : 'bg-gray-300 text-gray-500 cursor-not-allowed opacity-60'}`}
>
Update Swarm Endpoint
diff --git a/frontend/src/features/settings/components/shared/ProviderPanel.js b/frontend/src/features/settings/components/shared/ProviderPanel.js
index f8be052..4a3aa14 100644
--- a/frontend/src/features/settings/components/shared/ProviderPanel.js
+++ b/frontend/src/features/settings/components/shared/ProviderPanel.js
@@ -31,19 +31,19 @@
{id.substring(0, 2).toUpperCase()}
-
+
{id}
{status === 'success' && }
{status === 'error' && }
- {providerType}
+
{providerType}
{ e.stopPropagation(); handleVerifyProvider(sectionKey, id, prefs); }}
disabled={isVerifying}
- className={`px-3 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all ${isVerifying ? 'bg-gray-100 text-gray-400 animate-pulse' : 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-400'}`}
+ className={`px-3 py-1.5 rounded-lg text-[9px] font-black transition-all ${isVerifying ? 'bg-gray-100 text-gray-400 animate-pulse' : 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-400'}`}
>
{isVerifying ? 'Verifying...' : 'Test Connection'}
diff --git a/frontend/src/features/settings/pages/SettingsPage.js b/frontend/src/features/settings/pages/SettingsPage.js
index b479706..538c41a 100644
--- a/frontend/src/features/settings/pages/SettingsPage.js
+++ b/frontend/src/features/settings/pages/SettingsPage.js
@@ -254,13 +254,10 @@
try {
setSaving(true);
if (type === 'oidc') {
- // Mutual Exclusivity: If OIDC is being enabled, disable password login
- // Note: 'enabled' is the master switch that controls the login button.
- if (data.enabled === true && adminConfig.app?.allow_password_login) {
- await updateAdminAppConfig({ allow_password_login: false });
+ if (data.enabled === false && adminConfig.app?.allow_password_login === false) {
+ throw new Error("Cannot disable OIDC: you must leave at least one login method enabled.");
}
- // Consolidate 'allow_oidc_login' with 'enabled' to prevent redundancy
const updatedData = { ...data };
if (data.enabled !== undefined) {
updatedData.allow_oidc_login = data.enabled;
@@ -272,9 +269,8 @@
await updateAdminSwarmConfig(data);
setMessage({ type: 'success', text: 'Swarm configuration updated successfully' });
} else if (type === 'app') {
- // Mutual Exclusivity: If password login is being enabled, disable OIDC
- if (data.allow_password_login === true && adminConfig.oidc?.enabled) {
- await updateAdminOIDCConfig({ enabled: false, allow_oidc_login: false });
+ if (data.allow_password_login === false && adminConfig.oidc?.enabled === false) {
+ throw new Error("Cannot disable password login: you must leave at least one login method enabled.");
}
await updateAdminAppConfig(data);
setMessage({ type: 'success', text: 'Application configuration updated successfully' });
diff --git a/frontend/src/services/api/userService.js b/frontend/src/services/api/userService.js
index 5032949..75d6f5a 100644
--- a/frontend/src/services/api/userService.js
+++ b/frontend/src/services/api/userService.js
@@ -37,9 +37,12 @@
method: "GET",
headers: { "X-User-ID": "anonymous" },
});
- if (!response.ok) return { oidc_configured: false };
+ if (!response.ok) return { oidc_configured: false, allow_password_login: true };
const data = await response.json();
- return { oidc_configured: data.oidc_configured };
+ return {
+ oidc_configured: data.oidc_configured,
+ allow_password_login: data.allow_password_login
+ };
};
/**
@@ -57,6 +60,16 @@
};
/**
+ * Updates the user's local password.
+ */
+export const updatePassword = async (current_password, new_password) => {
+ return await fetchWithAuth('/users/password', {
+ method: 'PUT',
+ body: { current_password, new_password }
+ });
+};
+
+/**
* Fetches the user profile info.
*/
export const getUserProfile = async () => {