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:
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren