Add Super Admin panel with user impersonation

- New /super-admin/login and /super-admin routes with separate auth
- Super admin can view all registered accounts and impersonate any user
- Impersonation banner shows at top of screen with exit button
- ADMIN_USERNAME and ADMIN_PASSWORD config added to .env and docker-compose.yml
- Fixed auth store: export setToken, clearToken, and setUser so they are
  accessible from superAdmin store
- Updated README with super admin feature, new env vars, and setup notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:30:44 -08:00
parent a8e1b322f1
commit c560055b10
14 changed files with 600 additions and 6 deletions

View File

@@ -1,9 +1,28 @@
<template>
<RouterView />
<div>
<div v-if="superAdmin.isImpersonating" class="impersonation-banner">
<span>Viewing as <strong>{{ auth.user?.full_name || auth.user?.email }}</strong></span>
<button @click="exitImpersonation">Exit to Admin Panel</button>
</div>
<div :class="{ 'banner-offset': superAdmin.isImpersonating }">
<RouterView />
</div>
</div>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSuperAdminStore } from '@/stores/superAdmin'
const auth = useAuthStore()
const superAdmin = useSuperAdminStore()
const router = useRouter()
function exitImpersonation() {
superAdmin.exitImpersonation()
router.push('/super-admin')
}
</script>
<style>
@@ -25,3 +44,37 @@ a {
text-decoration: none;
}
</style>
<style scoped>
.impersonation-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
background: #f59e0b;
color: #0f172a;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.banner-offset {
padding-top: 2.25rem;
}
.impersonation-banner button {
padding: 0.25rem 0.75rem;
background: #0f172a;
color: #f59e0b;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSuperAdminStore } from '@/stores/superAdmin'
const routes = [
{
@@ -18,13 +19,25 @@ const routes = [
component: () => import('@/views/TVView.vue'),
meta: { public: true },
},
{
path: '/super-admin/login',
name: 'superAdminLogin',
component: () => import('@/views/SuperAdminLoginView.vue'),
meta: { public: true },
},
{
path: '/super-admin',
name: 'superAdmin',
component: () => import('@/views/SuperAdminView.vue'),
meta: { requiresAdminAuth: true },
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
{
path: '/logs',
name: 'logs',
component: () => import('@/views/LogView.vue'),
@@ -44,6 +57,14 @@ const router = createRouter({
})
router.beforeEach(async (to) => {
if (to.meta.requiresAdminAuth) {
const superAdmin = useSuperAdminStore()
if (!superAdmin.isAdminAuthenticated) {
return { name: 'superAdminLogin' }
}
return
}
if (to.meta.requiresAuth) {
const auth = useAuthStore()
if (!auth.isAuthenticated) {

View File

@@ -64,11 +64,18 @@ export const useAuthStore = defineStore('auth', () => {
}
}
function setUser(userData) {
user.value = userData
}
return {
accessToken,
user,
isAuthenticated,
timezone,
setToken,
clearToken,
setUser,
login,
register,
logout,

View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
export const useSuperAdminStore = defineStore('superAdmin', () => {
const adminToken = ref(localStorage.getItem('admin_token') || null)
const isAdminAuthenticated = computed(() => !!adminToken.value)
function setAdminToken(token) {
adminToken.value = token
localStorage.setItem('admin_token', token)
}
function clearAdminToken() {
adminToken.value = null
localStorage.removeItem('admin_token')
}
const auth = () => useAuthStore()
const isImpersonating = computed(() => !!adminToken.value && auth().isAuthenticated)
async function adminLogin(username, password) {
const res = await axios.post('/api/admin/login', { username, password })
setAdminToken(res.data.access_token)
}
async function impersonateUser(userId) {
// Resolve authStore before any awaits to avoid Pinia context issues
const authStore = useAuthStore()
const impersonateRes = await axios.post(
`/api/admin/impersonate/${userId}`,
{},
{ headers: { Authorization: `Bearer ${adminToken.value}` } },
)
const token = impersonateRes.data.access_token
// Fetch user data directly with the new token (bypasses the auto-refresh interceptor)
const userRes = await axios.get('/api/users/me', {
headers: { Authorization: `Bearer ${token}` },
})
authStore.setToken(token)
authStore.setUser(userRes.data)
}
function exitImpersonation() {
auth().clearToken()
}
function adminLogout() {
if (auth().isAuthenticated) {
auth().clearToken()
}
clearAdminToken()
}
return {
adminToken,
isAdminAuthenticated,
isImpersonating,
adminLogin,
impersonateUser,
exitImpersonation,
adminLogout,
}
})

View File

@@ -0,0 +1,131 @@
<template>
<div class="login-page">
<div class="login-card">
<h1>Super Admin</h1>
<p class="subtitle">Server-level access</p>
<form @submit.prevent="handleLogin">
<div class="field">
<label>Username</label>
<input v-model="username" type="text" autocomplete="username" required />
</div>
<div class="field">
<label>Password</label>
<input v-model="password" type="password" autocomplete="current-password" required />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSuperAdminStore } from '@/stores/superAdmin'
const router = useRouter()
const superAdmin = useSuperAdminStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
await superAdmin.adminLogin(username.value, password.value)
router.push('/super-admin')
} catch {
error.value = 'Invalid credentials'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f172a;
}
.login-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 360px;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #f8fafc;
margin-bottom: 0.25rem;
}
.subtitle {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.875rem;
color: #94a3b8;
margin-bottom: 0.375rem;
}
input {
width: 100%;
padding: 0.625rem 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #f1f5f9;
font-size: 0.9375rem;
outline: none;
}
input:focus {
border-color: #f59e0b;
}
.error {
color: #f87171;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
button {
width: 100%;
padding: 0.625rem;
background: #f59e0b;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9375rem;
margin-top: 0.5rem;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<div class="admin-page">
<header class="page-header">
<h1>Super Admin</h1>
<button class="logout-btn" @click="handleLogout">Logout</button>
</header>
<div class="content">
<h2>Registered Users</h2>
<p v-if="loading" class="loading">Loading</p>
<p v-else-if="error" class="error">{{ error }}</p>
<div v-else class="user-grid">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-info">
<div class="user-name">{{ u.full_name || '(no name)' }}</div>
<div class="user-email">{{ u.email }}</div>
<div class="user-meta">
<span :class="['badge', u.is_active ? 'active' : 'inactive']">
{{ u.is_active ? 'Active' : 'Inactive' }}
</span>
<span class="joined">Joined {{ formatDate(u.created_at) }}</span>
</div>
</div>
<button class="enter-btn" :disabled="entering === u.id" @click="enter(u.id)">
{{ entering === u.id ? 'Entering' : 'Enter as User' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useSuperAdminStore } from '@/stores/superAdmin'
const router = useRouter()
const superAdmin = useSuperAdminStore()
const users = ref([])
const loading = ref(true)
const error = ref('')
const entering = ref(null)
onMounted(async () => {
try {
const res = await axios.get('/api/admin/users', {
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
})
users.value = res.data
} catch {
error.value = 'Failed to load users'
} finally {
loading.value = false
}
})
async function enter(userId) {
entering.value = userId
try {
await superAdmin.impersonateUser(userId)
router.push('/dashboard')
} catch {
error.value = 'Failed to impersonate user'
entering.value = null
}
}
function handleLogout() {
superAdmin.adminLogout()
router.push('/super-admin/login')
}
function formatDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #0f172a;
color: #f1f5f9;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
border-bottom: 1px solid #1e293b;
background: #0f172a;
}
h1 {
font-size: 1.25rem;
font-weight: 700;
}
h2 {
font-size: 1rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 1rem;
}
.logout-btn {
padding: 0.4rem 0.9rem;
background: transparent;
border: 1px solid #334155;
border-radius: 6px;
color: #94a3b8;
cursor: pointer;
font-size: 0.875rem;
}
.logout-btn:hover {
border-color: #f87171;
color: #f87171;
}
.content {
padding: 2rem;
max-width: 960px;
margin: 0 auto;
}
.loading,
.error {
color: #94a3b8;
font-size: 0.9rem;
}
.error {
color: #f87171;
}
.user-grid {
display: grid;
gap: 0.75rem;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.user-name {
font-weight: 600;
margin-bottom: 0.15rem;
}
.user-email {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.user-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.active {
background: #14532d;
color: #4ade80;
}
.badge.inactive {
background: #292524;
color: #a8a29e;
}
.joined {
color: #64748b;
font-size: 0.8rem;
}
.enter-btn {
padding: 0.45rem 1rem;
background: #f59e0b;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
}
.enter-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>