feat: Global-Admin Org-Switcher fuer info@aegis-sight.de

Ermoeglicht dem Global Admin (is_global_admin Flag) zwischen
Organisationen zu wechseln. Neue Endpoints: GET /api/auth/organizations,
POST /api/auth/switch-org. Org-Dropdown im Header-Menue, nur fuer
Global Admin sichtbar. Komplett herausnehmbar (Flag + Code-Bloecke).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-04-08 22:25:41 +02:00
Ursprung d3e8c0adc7
Commit c22ae854fe
7 geänderte Dateien mit 178 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -20,6 +20,7 @@ def create_token(
role: str = "member", role: str = "member",
tenant_id: int = None, tenant_id: int = None,
org_slug: str = None, org_slug: str = None,
is_global_admin: bool = False,
) -> str: ) -> str:
"""JWT-Token erstellen mit Tenant-Kontext.""" """JWT-Token erstellen mit Tenant-Kontext."""
now = datetime.now(TIMEZONE) now = datetime.now(TIMEZONE)
@@ -31,6 +32,7 @@ def create_token(
"role": role, "role": role,
"tenant_id": tenant_id, "tenant_id": tenant_id,
"org_slug": org_slug, "org_slug": org_slug,
"is_global_admin": is_global_admin,
"iss": JWT_ISSUER, "iss": JWT_ISSUER,
"aud": JWT_AUDIENCE, "aud": JWT_AUDIENCE,
"iat": now, "iat": now,
@@ -69,6 +71,7 @@ async def get_current_user(
"role": payload.get("role", "member"), "role": payload.get("role", "member"),
"tenant_id": payload.get("tenant_id"), "tenant_id": payload.get("tenant_id"),
"org_slug": payload.get("org_slug"), "org_slug": payload.get("org_slug"),
"is_global_admin": payload.get("is_global_admin", False),
} }

Datei anzeigen

@@ -40,6 +40,7 @@ class UserMeResponse(BaseModel):
credits_total: Optional[int] = None credits_total: Optional[int] = None
credits_remaining: Optional[int] = None credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None credits_percent_used: Optional[float] = None
is_global_admin: bool = False
# Incidents (Lagen) # Incidents (Lagen)
@@ -206,3 +207,16 @@ class FeedbackRequest(BaseModel):
message: str = Field(min_length=10, max_length=5000) 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

Datei anzeigen

@@ -140,6 +140,13 @@ async def verify_magic_link(
) )
await db.commit() 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 # JWT erstellen
token = create_token( token = create_token(
user_id=ml["user_id"], user_id=ml["user_id"],
@@ -148,6 +155,7 @@ async def verify_magic_link(
role=ml["role"], role=ml["role"],
tenant_id=ml["organization_id"], tenant_id=ml["organization_id"],
org_slug=ml["org_slug"], org_slug=ml["org_slug"],
is_global_admin=_is_global_admin,
) )
return TokenResponse( return TokenResponse(
@@ -208,4 +216,63 @@ async def get_me(
license_status=license_info.get("status", "unknown"), license_status=license_info.get("status", "unknown"),
license_type=license_info.get("license_type", ""), license_type=license_info.get("license_type", ""),
read_only=license_info.get("read_only", False), 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"],
}

Datei anzeigen

@@ -5445,3 +5445,41 @@ body.tutorial-active .tutorial-cursor {
font-size: 11px; font-size: 11px;
color: var(--text-tertiary); 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);
}

Datei anzeigen

@@ -52,6 +52,12 @@
<span class="header-dropdown-label">Organisation</span> <span class="header-dropdown-label">Organisation</span>
<span class="header-dropdown-value" id="header-org-name">-</span> <span class="header-dropdown-value" id="header-org-name">-</span>
</div> </div>
<!-- Global Admin: Org-Switcher (herausnehmbar) -->
<div id="org-switcher-section" class="org-switcher-section" style="display: none;">
<div class="credits-divider"></div>
<label class="org-switcher-label" for="org-switcher-select">Wechseln zu:</label>
<select id="org-switcher-select" class="org-switcher-select"></select>
</div>
<div class="header-dropdown-row"> <div class="header-dropdown-row">
<span class="header-dropdown-label">Lizenz</span> <span class="header-dropdown-label">Lizenz</span>
<span class="header-dropdown-value" id="header-license-info">-</span> <span class="header-dropdown-value" id="header-license-info">-</span>

Datei anzeigen

@@ -243,4 +243,13 @@ const API = {
headers: { 'Authorization': `Bearer ${token}` }, 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 });
},
}; };

Datei anzeigen

@@ -515,6 +515,11 @@ const App = {
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff'; warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
warningEl.classList.add('visible'); warningEl.classList.add('visible');
} }
// --- Global Admin: Org-Switcher (herausnehmbar) ---
if (user.is_global_admin) {
this._initOrgSwitcher(user.tenant_id);
}
} catch { } catch {
window.location.href = '/'; window.location.href = '/';
return; 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() { logout() {
localStorage.removeItem('osint_token'); localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username'); localStorage.removeItem('osint_username');