diff --git a/src/auth.py b/src/auth.py index ee017bb..0371811 100644 --- a/src/auth.py +++ b/src/auth.py @@ -20,6 +20,7 @@ def create_token( role: str = "member", tenant_id: int = None, org_slug: str = None, + is_global_admin: bool = False, ) -> str: """JWT-Token erstellen mit Tenant-Kontext.""" now = datetime.now(TIMEZONE) @@ -31,6 +32,7 @@ def create_token( "role": role, "tenant_id": tenant_id, "org_slug": org_slug, + "is_global_admin": is_global_admin, "iss": JWT_ISSUER, "aud": JWT_AUDIENCE, "iat": now, @@ -69,6 +71,7 @@ async def get_current_user( "role": payload.get("role", "member"), "tenant_id": payload.get("tenant_id"), "org_slug": payload.get("org_slug"), + "is_global_admin": payload.get("is_global_admin", False), } diff --git a/src/models.py b/src/models.py index 176fb86..d7bbc58 100644 --- a/src/models.py +++ b/src/models.py @@ -40,6 +40,7 @@ class UserMeResponse(BaseModel): credits_total: Optional[int] = None credits_remaining: Optional[int] = None credits_percent_used: Optional[float] = None + is_global_admin: bool = False # Incidents (Lagen) @@ -206,3 +207,16 @@ class FeedbackRequest(BaseModel): message: str = Field(min_length=10, max_length=5000) + + +# --- Global Admin: Org-Wechsel (herausnehmbar) --- + +class SwitchOrgRequest(BaseModel): + organization_id: int + + +class OrgListItem(BaseModel): + id: int + name: str + slug: str + is_active: bool diff --git a/src/routers/auth.py b/src/routers/auth.py index fbd7431..21c5cc3 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -140,6 +140,13 @@ async def verify_magic_link( ) await db.commit() + # Global-Admin-Flag aus DB lesen + ga_cursor = await db.execute( + "SELECT is_global_admin FROM users WHERE id = ?", (ml["user_id"],) + ) + ga_row = await ga_cursor.fetchone() + _is_global_admin = bool(ga_row["is_global_admin"]) if ga_row else False + # JWT erstellen token = create_token( user_id=ml["user_id"], @@ -148,6 +155,7 @@ async def verify_magic_link( role=ml["role"], tenant_id=ml["organization_id"], org_slug=ml["org_slug"], + is_global_admin=_is_global_admin, ) return TokenResponse( @@ -208,4 +216,63 @@ async def get_me( license_status=license_info.get("status", "unknown"), license_type=license_info.get("license_type", ""), read_only=license_info.get("read_only", False), + is_global_admin=current_user.get("is_global_admin", False), ) + + +# --- Global Admin: Org-Wechsel (herausnehmbar) --- + +from models import SwitchOrgRequest, OrgListItem + + +@router.get("/organizations") +async def list_all_organizations( + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Alle Organisationen auflisten (nur fuer Global Admin).""" + if not current_user.get("is_global_admin"): + raise HTTPException(status_code=403, detail="Keine Berechtigung") + + cursor = await db.execute( + "SELECT id, name, slug, is_active FROM organizations ORDER BY name" + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +@router.post("/switch-org") +async def switch_organization( + data: SwitchOrgRequest, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Organisation wechseln (nur fuer Global Admin). Gibt neues JWT zurueck.""" + if not current_user.get("is_global_admin"): + raise HTTPException(status_code=403, detail="Keine Berechtigung") + + # Ziel-Org pruefen + cursor = await db.execute( + "SELECT id, name, slug FROM organizations WHERE id = ?", (data.organization_id,) + ) + org = await cursor.fetchone() + if not org: + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + + # Neues JWT mit anderem tenant_id ausstellen + token = create_token( + user_id=current_user["id"], + username=current_user["username"], + email=current_user["email"], + role=current_user["role"], + tenant_id=org["id"], + org_slug=org["slug"], + is_global_admin=True, + ) + + return { + "access_token": token, + "token_type": "bearer", + "org_name": org["name"], + "org_slug": org["slug"], + } diff --git a/src/static/css/style.css b/src/static/css/style.css index aea6adb..0d4469c 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -5445,3 +5445,41 @@ body.tutorial-active .tutorial-cursor { font-size: 11px; color: var(--text-tertiary); } + +/* --- Global Admin: Org-Switcher (herausnehmbar) --- */ +.org-switcher-section { + padding: 0; + text-align: left; +} + +.org-switcher-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-tertiary); + text-transform: uppercase; + margin-bottom: 6px; + display: block; +} + +.org-switcher-select { + width: 100%; + padding: 6px 8px; + font-size: 13px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + outline: none; + transition: border-color 0.15s; +} + +.org-switcher-select:hover { + border-color: var(--accent); +} + +.org-switcher-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb, 59, 130, 246), 0.15); +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 410ae8a..3b246a6 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -52,6 +52,12 @@ Organisation - + +
Lizenz - diff --git a/src/static/js/api.js b/src/static/js/api.js index 07db045..f76d138 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -243,4 +243,13 @@ const API = { headers: { 'Authorization': `Bearer ${token}` }, }); }, + + // --- Global Admin: Org-Wechsel (herausnehmbar) --- + listOrganizations() { + return this._request('GET', '/auth/organizations'); + }, + + switchOrg(organizationId) { + return this._request('POST', '/auth/switch-org', { organization_id: organizationId }); + }, }; diff --git a/src/static/js/app.js b/src/static/js/app.js index b66880f..92c9500 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -515,6 +515,11 @@ const App = { warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff'; warningEl.classList.add('visible'); } + + // --- Global Admin: Org-Switcher (herausnehmbar) --- + if (user.is_global_admin) { + this._initOrgSwitcher(user.tenant_id); + } } catch { window.location.href = '/'; return; @@ -2939,6 +2944,42 @@ async handleRefresh() { } }, + // --- Global Admin: Org-Switcher (herausnehmbar) --- + async _initOrgSwitcher(currentTenantId) { + const section = document.getElementById('org-switcher-section'); + const select = document.getElementById('org-switcher-select'); + if (!section || !select) return; + + try { + const orgs = await API.listOrganizations(); + if (!orgs || orgs.length < 2) return; + + section.style.display = 'block'; + select.innerHTML = ''; + orgs.forEach(org => { + const opt = document.createElement('option'); + opt.value = org.id; + opt.textContent = org.name + (org.is_active ? '' : ' (inaktiv)'); + if (org.id === currentTenantId) opt.selected = true; + select.appendChild(opt); + }); + + select.addEventListener('change', async () => { + const orgId = parseInt(select.value, 10); + if (orgId === currentTenantId) return; + try { + const result = await API.switchOrg(orgId); + localStorage.setItem('osint_token', result.access_token); + window.location.reload(); + } catch (err) { + console.error('Org-Wechsel fehlgeschlagen:', err); + } + }); + } catch { + // Kein Global Admin oder Fehler - Switcher bleibt versteckt + } + }, + logout() { localStorage.removeItem('osint_token'); localStorage.removeItem('osint_username');