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:
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
130
app/templates/admin/select-platform.html
Normal file
130
app/templates/admin/select-platform.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
156
static/admin/js/select-platform.js
Normal file
156
static/admin/js/select-platform.js
Normal 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');
|
||||
Reference in New Issue
Block a user