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 @@ )} -
-
- - setEmail(e.target.value)} - className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" - placeholder="admin@example.com" - autoComplete="email" - /> -
-
- - setPassword(e.target.value)} - className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" - placeholder="••••••••" - autoComplete="current-password" - /> -
- -
+ {passwordEnabled && ( +
+
+ + setEmail(e.target.value)} + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" + placeholder="admin@example.com" + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" + placeholder="••••••••" + autoComplete="current-password" + /> +
+ +
+ )} {oidcEnabled && (
-
-
- Or -
-
+ {passwordEnabled && ( +
+
+ Or +
+
+ )} 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 && ( -
- +
+ {/* General Information */} +
+

+ + General Information +

+
+
+ + setEditData({ ...editData, full_name: e.target.value })} + placeholder="Enter your full name" + /> +
+
+ + setEditData({ ...editData, username: e.target.value })} + placeholder="Display name" + /> +
+
+ +

+ {profile.group_name || 'Ungrouped'} +

+

Groups are managed by your administrator.

+
+
+ +
+
+
+ + {/* Account Security */} +
+

+ + + + Account Security +

+
+
+ + setPasswordData({...passwordData, current: e.target.value})} + placeholder="Leave empty if not set" + /> +
+
+ + setPasswordData({...passwordData, new: e.target.value})} + placeholder="Enter new password" + /> +
+
+ +
+
+
{/* Service Preferences */} @@ -258,66 +321,7 @@ />
- {/* Voice Experience Section */} -
-

- - - - Voice Chat Experience -

- -
-
- - 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.

-
- -
-
-
- -

Automatically reactivates microphone after AI finishes speaking.

-
- -
-
-
- - 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 @@

@@ -97,20 +97,20 @@
⚠️
-

Final Confirmation

+

Final Confirmation

{confirmAction.label}

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 @@ @@ -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 @@
-

{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 @@
- +
{['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => (
- {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} + {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`}
-
@@ -311,10 +311,10 @@ - - - - + + + + @@ -327,7 +327,7 @@

{u.username || u.email}

-

{u.role}

+

{u.role}

@@ -347,11 +347,11 @@
MemberPolicy GroupActivity AuditingActionsMemberPolicy GroupActivity AuditingActions
- 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 @@ @@ -72,32 +104,37 @@
{/* OIDC Details */} -
+
-

+

OIDC Configuration Details

-

Enterprise Social Login & Identity Synchronization

+

Enterprise Social Login & Identity Synchronization

- {/* 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! + )}
- {/* Swarm Details */} -
+ {/* Swarm Endpoint Details */} +
-

+

Swarm Access Configuration

-

Infrastructure gRPC Connectivity & Discovery

+

Infrastructure gRPC Connectivity & Discovery

-
+
- {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! + )} 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}

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 () => {