feat: add platform selection frontend for platform admins

Frontend implementation of platform admin flow:
- Update login.js to check for platform selection after login
- Add platform selection page (/admin/select-platform)
- Add platform context indicator in admin header
- Add is_super_admin to UserResponse schema
- Show "Super Admin" badge or platform name with switch option

Platform admins now:
1. Login normally at /admin/login
2. Get redirected to /admin/select-platform if they have multiple platforms
3. See current platform in header with option to switch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-24 18:54:59 +01:00
parent 53e05dd497
commit 300f49c5a1
6 changed files with 406 additions and 3 deletions

View File

@@ -95,6 +95,32 @@ async def admin_login_page(
return templates.TemplateResponse("admin/login.html", {"request": request})
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
async def admin_select_platform_page(
request: Request,
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render platform selection page for platform admins.
Platform admins with access to multiple platforms must select
which platform they want to manage before accessing the dashboard.
Super admins are redirected to dashboard (they have global access).
"""
if not current_user:
# Not logged in, redirect to login
return RedirectResponse(url="/admin/login", status_code=302)
if current_user.is_super_admin:
# Super admins don't need platform selection
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse(
"admin/select-platform.html",
{"request": request, "user": current_user},
)
# ============================================================================
# AUTHENTICATED ROUTES (Admin Only)
# ============================================================================

View File

@@ -20,6 +20,27 @@
</div>
<ul class="flex items-center flex-shrink-0 space-x-6">
<!-- Platform Context Indicator (for platform admins) -->
<li class="flex" x-data="platformContext()">
<template x-if="platform && !isSuperAdmin">
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-xs font-medium text-purple-700 bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 rounded-full">
<span x-text="platform.name"></span>
</span>
<a href="/admin/select-platform"
class="text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="Switch platform">
<span x-html="$icon('switch-horizontal', 'w-4 h-4')"></span>
</a>
</div>
</template>
<template x-if="isSuperAdmin">
<span class="px-3 py-1 text-xs font-medium text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/30 rounded-full">
Super Admin
</span>
</template>
</li>
<!-- Theme toggler -->
<li class="flex">
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
@@ -182,6 +203,53 @@
<!-- Header notifications and logout handler script -->
<script>
// Platform context component for header
function platformContext() {
return {
platform: null,
isSuperAdmin: false,
init() {
// Try to get platform from localStorage
const storedPlatform = localStorage.getItem('admin_platform');
if (storedPlatform) {
try {
this.platform = JSON.parse(storedPlatform);
} catch (e) {
console.warn('Failed to parse stored platform:', e);
}
}
// Check if user is super admin
const storedUser = localStorage.getItem('admin_user');
if (storedUser) {
try {
const user = JSON.parse(storedUser);
this.isSuperAdmin = user.is_super_admin === true;
} catch (e) {
console.warn('Failed to parse stored user:', e);
}
}
}
};
}
// Header messages component
function headerMessages() {
return {
unreadCount: 0,
async init() {
try {
const response = await apiClient.get('/admin/messages/unread-count');
this.unreadCount = response.count || 0;
} catch (error) {
// Silently fail - messages count is not critical
}
}
};
}
// Header notifications component
function headerNotifications() {
return {
@@ -288,6 +356,7 @@ document.addEventListener('alpine:init', () => {
// Keep admin_last_visited_page so user returns to same page after login
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('admin_platform');
// Note: Do NOT use localStorage.clear() - it would clear vendor/customer tokens too
window.location.href = '/admin/login';
});

View File

@@ -0,0 +1,130 @@
{# app/templates/admin/select-platform.html #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="selectPlatform()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Select Platform - Admin Panel</title>
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<style>[x-cloak] { display: none !important; }</style>
</head>
<body x-cloak>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900">
<div class="flex-1 h-full max-w-xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto">
<div class="flex items-center justify-center p-6 sm:p-12">
<div class="w-full">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Select Platform
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Choose a platform to manage
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-8">
<svg class="animate-spin h-8 w-8 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Error State -->
<div x-show="error" x-cloak class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
</div>
<!-- Super Admin Notice -->
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-blue-700 dark:text-blue-400">
You are a Super Admin with access to all platforms. Redirecting to dashboard...
</p>
</div>
<!-- Platform List -->
<div x-show="!loading && !isSuperAdmin && platforms.length > 0" x-cloak class="space-y-3">
<template x-for="platform in platforms" :key="platform.id">
<button
@click="selectPlatform(platform)"
:disabled="selecting"
class="w-full flex items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<!-- Platform Icon/Logo -->
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center mr-4">
<template x-if="platform.logo">
<img :src="platform.logo" :alt="platform.name" class="w-8 h-8 object-contain">
</template>
<template x-if="!platform.logo">
<span class="text-xl font-bold text-purple-600 dark:text-purple-400" x-text="platform.code.charAt(0).toUpperCase()"></span>
</template>
</div>
<!-- Platform Info -->
<div class="flex-1 text-left">
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200" x-text="platform.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
</div>
<!-- Arrow -->
<div class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</button>
</template>
</div>
<!-- No Platforms -->
<div x-show="!loading && !isSuperAdmin && platforms.length === 0" x-cloak class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-200">No platforms assigned</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Contact your administrator to get platform access.</p>
</div>
<!-- Logout Link -->
<div class="mt-8 text-center">
<button
@click="logout()"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 underline"
>
Sign out and use a different account
</button>
</div>
<!-- Theme Toggle -->
<div class="mt-4 flex justify-center">
<button
@click="toggleDarkMode()"
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label="Toggle dark mode"
>
<svg x-show="!dark" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg x-show="dark" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script src="{{ url_for('static', path='admin/js/select-platform.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
</body>
</html>