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:
Vantz Stockwell
2026-02-15 02:07:38 -05:00
parent 0ac1738c85
commit 88b50a30b4
16 changed files with 711 additions and 52 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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"
>