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>

View File

@@ -25,6 +25,7 @@ class UserResponse(BaseModel):
username: str
role: str
is_active: bool
is_super_admin: bool = False
preferred_language: str | None = None
last_login: datetime | None = None
created_at: datetime

View File

@@ -166,7 +166,8 @@ function adminLogin() {
loginLog.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id
id: response.user.id,
is_super_admin: response.user.is_super_admin
});
}
@@ -180,12 +181,32 @@ function adminLogin() {
});
// Show success message
this.success = 'Login successful! Redirecting...';
this.success = 'Login successful! Checking platform access...';
loginLog.info('Success message displayed to user');
// Check if platform selection is required
try {
loginLog.info('Checking accessible platforms...');
const platformsResponse = await apiClient.get('/api/v1/admin/auth/accessible-platforms');
loginLog.debug('Accessible platforms response:', platformsResponse);
if (platformsResponse.requires_platform_selection) {
// Platform admin needs to select a platform
loginLog.info('Platform selection required, redirecting...');
this.success = 'Login successful! Please select a platform...';
window.location.href = '/admin/select-platform';
return;
}
} catch (platformError) {
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
}
// Super admin or single platform - proceed to dashboard
this.success = 'Login successful! Redirecting...';
// Check for last visited page (saved before logout)
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login'))
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';

View File

@@ -0,0 +1,156 @@
// static/admin/js/select-platform.js
// Platform selection page for platform admins
const platformLog = window.LogConfig ? window.LogConfig.createLogger('PLATFORM_SELECT') : console;
function selectPlatform() {
return {
dark: false,
loading: true,
selecting: false,
error: null,
platforms: [],
isSuperAdmin: false,
async init() {
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
// Set theme
this.dark = localStorage.getItem('theme') === 'dark';
// Check if user is logged in
const token = localStorage.getItem('admin_token');
if (!token) {
platformLog.warn('No token found, redirecting to login');
window.location.href = '/admin/login';
return;
}
// Load accessible platforms
await this.loadPlatforms();
},
async loadPlatforms() {
this.loading = true;
this.error = null;
try {
platformLog.info('Fetching accessible platforms...');
const response = await apiClient.get('/api/v1/admin/auth/accessible-platforms');
platformLog.debug('Platforms response:', response);
this.isSuperAdmin = response.is_super_admin;
this.platforms = response.platforms || [];
if (this.isSuperAdmin) {
platformLog.info('User is super admin, redirecting to dashboard...');
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 1500);
return;
}
if (!response.requires_platform_selection && this.platforms.length === 1) {
// Only one platform assigned, auto-select it
platformLog.info('Single platform assigned, auto-selecting...');
await this.selectPlatform(this.platforms[0]);
return;
}
platformLog.info(`Loaded ${this.platforms.length} platforms`);
} catch (error) {
platformLog.error('Failed to load platforms:', error);
if (error.message && error.message.includes('401')) {
// Token expired or invalid
window.location.href = '/admin/login';
return;
}
this.error = error.message || 'Failed to load platforms. Please try again.';
} finally {
this.loading = false;
}
},
async selectPlatform(platform) {
if (this.selecting) return;
this.selecting = true;
this.error = null;
platformLog.info(`Selecting platform: ${platform.code}`);
try {
const response = await apiClient.post(
`/api/v1/admin/auth/select-platform?platform_id=${platform.id}`
);
platformLog.debug('Platform selection response:', response);
if (response.access_token) {
// Store new token with platform context
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token);
// Store selected platform info
localStorage.setItem('admin_platform', JSON.stringify({
id: platform.id,
code: platform.code,
name: platform.name
}));
// Update user data if provided
if (response.user) {
localStorage.setItem('admin_user', JSON.stringify(response.user));
}
platformLog.info('Platform selected successfully, redirecting to dashboard...');
// Redirect to dashboard or last visited page
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';
window.location.href = redirectTo;
} else {
throw new Error('No token received from server');
}
} catch (error) {
platformLog.error('Platform selection failed:', error);
this.error = error.message || 'Failed to select platform. Please try again.';
this.selecting = false;
}
},
logout() {
platformLog.info('Logging out...');
fetch('/api/v1/admin/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
})
.catch((error) => {
platformLog.error('Logout API error:', error);
})
.finally(() => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('admin_platform');
localStorage.removeItem('token');
window.location.href = '/admin/login';
});
},
toggleDarkMode() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
}
};
}
platformLog.info('Platform selection module loaded');