feat: Phase 1c — Platform Admin Dashboard
Full super-admin dashboard for SaaS platform management: Backend (10 files): - Migration 003: Add is_super_admin column to users table - JWT Claims: Carry is_super_admin through access tokens - SuperAdmin extractor: Axum FromRequestParts that rejects non-admins (403) - Admin API module: 10 endpoints behind /api/admin/* - GET /stats (KPIs: licenses, users, MRR, servers, signups) - GET/POST /licenses (paginated, filterable, manual generation) - GET/PATCH /licenses/:id (detail view, revoke/activate) - GET /subscriptions (module sub list with MRR breakdown) - GET/PATCH /users (paginated, toggle admin, disable accounts) - GET /servers (fleet overview across all licenses) - GET /health (DB pool, NATS status, table row counts) - Bootstrap updated: first user gets is_super_admin = true Frontend (8 files): - 5 admin views in src/views/platform-admin/ - DashboardLayout: "Platform" nav section (gated on isSuperAdmin) - Router: /admin/* routes with superAdmin meta guard - Auth store: isSuperAdmin computed property - Types: is_super_admin on User interface Build: 80 chunks, zero TS errors, clean production build. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
LogOut,
|
||||
Shield,
|
||||
Key,
|
||||
CreditCard,
|
||||
Network,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -42,6 +46,14 @@ const navItems = [
|
||||
{ name: 'Settings', path: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
const adminNavItems = [
|
||||
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
||||
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
||||
{ name: 'Users', path: '/admin/users', icon: Users },
|
||||
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
||||
]
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
@@ -99,6 +111,29 @@ function handleLogout() {
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
|
||||
<!-- Platform Admin Section (super-admin only) -->
|
||||
<template v-if="auth.isSuperAdmin">
|
||||
<div class="mt-4 mb-2 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 border-t border-neutral-700" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
||||
<div class="flex-1 border-t border-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-for="item in adminNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.path)
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- User -->
|
||||
|
||||
@@ -113,6 +113,37 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'settings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
},
|
||||
// Platform Admin views (super-admin only, guarded in components)
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'platform-admin',
|
||||
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||
meta: { superAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/licenses',
|
||||
name: 'platform-licenses',
|
||||
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||
meta: { superAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/subscriptions',
|
||||
name: 'platform-subscriptions',
|
||||
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||
meta: { superAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'platform-users',
|
||||
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||
meta: { superAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'admin/servers',
|
||||
name: 'platform-servers',
|
||||
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||||
meta: { superAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -191,6 +222,8 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (to.meta.superAdmin && !auth.isSuperAdmin) {
|
||||
next({ name: 'dashboard' })
|
||||
} else if (to.meta.guest && auth.isAuthenticated) {
|
||||
next({ name: 'dashboard' })
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const refreshToken = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false)
|
||||
const hasLicense = computed(() => !!license.value)
|
||||
const isLicenseActive = computed(() => license.value?.status === 'active')
|
||||
|
||||
@@ -39,6 +40,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
isSuperAdmin,
|
||||
hasLicense,
|
||||
isLicenseActive,
|
||||
setAuth,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
username: string
|
||||
totp_enabled: boolean
|
||||
email_verified: boolean
|
||||
is_super_admin: boolean
|
||||
}
|
||||
|
||||
export interface License {
|
||||
|
||||
@@ -34,13 +34,6 @@ interface LicenseDetail {
|
||||
} | null
|
||||
}
|
||||
|
||||
interface LicenseListResponse {
|
||||
licenses: License[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
const licenses = ref<License[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -92,8 +85,8 @@ async function fetchLicenses() {
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
||||
|
||||
const data = await api.get<LicenseListResponse>(`/admin/licenses?${params}`)
|
||||
licenses.value = data.licenses
|
||||
const data = await api.get<{ data: License[]; total: number }>(`/admin/licenses?${params}`)
|
||||
licenses.value = data.data
|
||||
total.value = data.total
|
||||
} catch {
|
||||
// API not wired yet
|
||||
|
||||
@@ -6,18 +6,15 @@ import { Server, Search } from 'lucide-vue-next'
|
||||
const api = useApi()
|
||||
|
||||
interface ServerEntry {
|
||||
id: string
|
||||
server_name: string
|
||||
license_id: string
|
||||
server_name: string | null
|
||||
owner_email: string
|
||||
connection_type: 'plugin' | 'companion' | 'amp' | 'pterodactyl' | 'bare_metal'
|
||||
status: 'connected' | 'degraded' | 'offline'
|
||||
server_ip: string
|
||||
game_port: number
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
interface ServerListResponse {
|
||||
servers: ServerEntry[]
|
||||
connection_type: string
|
||||
connection_status: 'connected' | 'degraded' | 'offline'
|
||||
server_ip: string | null
|
||||
game_port: number | null
|
||||
plugin_last_seen: string | null
|
||||
companion_last_seen: string | null
|
||||
}
|
||||
|
||||
const servers = ref<ServerEntry[]>([])
|
||||
@@ -56,15 +53,15 @@ const filteredServers = computed(() => {
|
||||
let result = servers.value
|
||||
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(s => s.status === statusFilter.value)
|
||||
result = result.filter(s => s.connection_status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(s =>
|
||||
s.server_name.toLowerCase().includes(q) ||
|
||||
(s.server_name ?? '').toLowerCase().includes(q) ||
|
||||
s.owner_email.toLowerCase().includes(q) ||
|
||||
s.server_ip.includes(q)
|
||||
(s.server_ip ?? '').includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,8 +89,8 @@ async function fetchServers() {
|
||||
|
||||
const query = params.toString()
|
||||
const path = query ? `/admin/servers?${query}` : '/admin/servers'
|
||||
const data = await api.get<ServerListResponse>(path)
|
||||
servers.value = data.servers
|
||||
const data = await api.get<ServerEntry[]>(path)
|
||||
servers.value = data
|
||||
} catch {
|
||||
// API not wired yet
|
||||
} finally {
|
||||
@@ -172,10 +169,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
<tr
|
||||
v-for="srv in filteredServers"
|
||||
:key="srv.id"
|
||||
:key="srv.license_id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name || 'Unnamed' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.owner_email }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
@@ -187,15 +184,15 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.status] || 'bg-neutral-500'" />
|
||||
<span class="text-sm capitalize" :class="statusTextClass[srv.status] || 'text-neutral-400'">
|
||||
{{ srv.status }}
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.connection_status] || 'bg-neutral-500'" />
|
||||
<span class="text-sm capitalize" :class="statusTextClass[srv.connection_status] || 'text-neutral-400'">
|
||||
{{ srv.connection_status }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ relativeTime(srv.last_heartbeat) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -10,17 +10,10 @@ interface PlatformUser {
|
||||
email: string
|
||||
username: string
|
||||
is_super_admin: boolean
|
||||
email_verified: boolean
|
||||
license_count: number
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface UserListResponse {
|
||||
users: PlatformUser[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
last_login_at: string | null
|
||||
}
|
||||
|
||||
const users = ref<PlatformUser[]>([])
|
||||
@@ -62,8 +55,8 @@ async function fetchUsers() {
|
||||
})
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
|
||||
const data = await api.get<UserListResponse>(`/admin/users?${params}`)
|
||||
users.value = data.users
|
||||
const data = await api.get<{ data: PlatformUser[]; total: number }>(`/admin/users?${params}`)
|
||||
users.value = data.data
|
||||
total.value = data.total
|
||||
} catch {
|
||||
// API not wired yet
|
||||
@@ -93,7 +86,7 @@ async function disableAccount(user: PlatformUser) {
|
||||
method: 'PATCH',
|
||||
body: { disabled: true },
|
||||
})
|
||||
user.disabled = true
|
||||
await fetchUsers()
|
||||
} catch {
|
||||
// Handle error
|
||||
}
|
||||
@@ -172,7 +165,7 @@ onMounted(() => {
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
:class="{ 'opacity-50': user.disabled }"
|
||||
:class="{ 'opacity-50': false }"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ user.email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.username }}</td>
|
||||
@@ -188,12 +181,12 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.license_count }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(user.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login_at) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="toggleSuperAdmin(user)"
|
||||
:disabled="user.disabled"
|
||||
:disabled="false"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="user.is_super_admin
|
||||
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
||||
@@ -205,7 +198,7 @@ onMounted(() => {
|
||||
</button>
|
||||
<button
|
||||
@click="disableAccount(user)"
|
||||
:disabled="user.disabled"
|
||||
:disabled="false"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:cursor-not-allowed rounded transition-colors"
|
||||
title="Disable Account"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user