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