feat: integrate MarkBase Admin API with Bearer token authentication
- Added useAdminAuth composable for login/logout/token management - Admin Users tab now requires login before showing users - loadUsers/loadShares now use direct fetch with Bearer token - Added login form with username/password fields - Token stored in localStorage with expiry check - Default credentials: demo/demo123
This commit is contained in:
Binary file not shown.
@@ -150,9 +150,23 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
|
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
|
||||||
const base = getApiBase()
|
const base = getApiBase()
|
||||||
const { url, method, body, timeout: requestTimeout } = buildHttpRequest(cmd, args)
|
const { url, method, body, timeout: requestTimeout, requiresAdminToken } = buildHttpRequest(cmd, args)
|
||||||
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
|
const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${base}${url}`
|
||||||
|
|
||||||
|
// Get admin token from localStorage if needed
|
||||||
|
const headers: Record<string, string> = body ? { 'Content-Type': 'application/json' } : {}
|
||||||
|
if (requiresAdminToken) {
|
||||||
|
const stored = localStorage.getItem('mb_admin_token')
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stored)
|
||||||
|
if (new Date(data.expires_at) > new Date()) {
|
||||||
|
headers['Authorization'] = `Bearer ${data.token}`
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT)
|
const timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
@@ -162,7 +176,7 @@ async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Pr
|
|||||||
try {
|
try {
|
||||||
response = await fetch(fullUrl, {
|
response = await fetch(fullUrl, {
|
||||||
method,
|
method,
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -238,7 +252,7 @@ async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any; timeout?: number } {
|
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any; timeout?: number; requiresAdminToken?: boolean } {
|
||||||
const a = args
|
const a = args
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
@@ -668,9 +682,11 @@ case 'search_keyword': {
|
|||||||
return { url, method: 'GET' }
|
return { url, method: 'GET' }
|
||||||
}
|
}
|
||||||
case 'list_admin_users':
|
case 'list_admin_users':
|
||||||
return { url: '/api/v2/admin/users', method: 'GET' }
|
return { url: '/api/v2/admin/users', method: 'GET', requiresAdminToken: true }
|
||||||
case 'list_shares':
|
case 'list_shares':
|
||||||
return { url: '/api/v2/admin/shares', method: 'GET' }
|
return { url: '/api/v2/admin/shares', method: 'GET' }
|
||||||
|
case 'admin_login':
|
||||||
|
return { url: '/api/v2/admin/login', method: 'POST', body: { username: a.username, password: a.password } }
|
||||||
|
|
||||||
// --- Pose & Appearance ---
|
// --- Pose & Appearance ---
|
||||||
case 'get_pose': {
|
case 'get_pose': {
|
||||||
|
|||||||
76
src/composables/useAdminAuth.ts
Normal file
76
src/composables/useAdminAuth.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { getApiBase } from '@/api/config'
|
||||||
|
|
||||||
|
export interface AdminToken {
|
||||||
|
token: string
|
||||||
|
expires_at: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminAuth() {
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
const username = ref<string | null>(null)
|
||||||
|
const expiresAt = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Load token from localStorage on init
|
||||||
|
const stored = localStorage.getItem('mb_admin_token')
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stored)
|
||||||
|
if (new Date(data.expires_at) > new Date()) {
|
||||||
|
token.value = data.token
|
||||||
|
username.value = data.username
|
||||||
|
expiresAt.value = data.expires_at
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('mb_admin_token')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('mb_admin_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(usernameVal: string, password: string): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const base = getApiBase()
|
||||||
|
const resp = await fetch(`${base}/api/v2/admin/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: usernameVal, password })
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
if (!resp.ok) throw new Error(data.error || 'Login failed')
|
||||||
|
|
||||||
|
token.value = data.token
|
||||||
|
username.value = data.username
|
||||||
|
expiresAt.value = data.expires_at
|
||||||
|
localStorage.setItem('mb_admin_token', JSON.stringify(data))
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Login failed'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null
|
||||||
|
username.value = null
|
||||||
|
expiresAt.value = null
|
||||||
|
localStorage.removeItem('mb_admin_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoggedIn(): boolean {
|
||||||
|
return !!token.value && (expiresAt.value ? new Date(expiresAt.value) > new Date() : false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeader(): Record<string, string> {
|
||||||
|
return token.value ? { 'Authorization': `Bearer ${token.value}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, expiresAt, loading, error, login, logout, isLoggedIn, getAuthHeader }
|
||||||
|
}
|
||||||
@@ -141,6 +141,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'users'" class="users-panel">
|
<div v-if="activeTab === 'users'" class="users-panel">
|
||||||
|
<!-- Admin Login -->
|
||||||
|
<div v-if="!adminAuth.isLoggedIn()" class="admin-login-form">
|
||||||
|
<h3>Admin Login</h3>
|
||||||
|
<div class="login-row">
|
||||||
|
<input class="login-input" v-model="loginUsername" placeholder="Username" @keyup.enter="handleLogin" />
|
||||||
|
<input class="login-input" v-model="loginPassword" type="password" placeholder="Password" @keyup.enter="handleLogin" />
|
||||||
|
<button class="login-btn" @click="handleLogin" :disabled="adminAuth.loading.value">{{ adminAuth.loading.value ? 'Logging in...' : 'Login' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="adminAuth.error.value" class="login-error">{{ adminAuth.error.value }}</div>
|
||||||
|
<div class="login-hint">Default: demo / demo123</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<template v-if="adminAuth.isLoggedIn()">
|
||||||
|
<div class="users-header">
|
||||||
|
<span>Logged in as: {{ adminAuth.username.value }}</span>
|
||||||
|
<button class="logout-btn" @click="handleLogout">Logout</button>
|
||||||
|
</div>
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead><tr><th>{{ t('admin.table.username') }}</th><th>{{ t('admin.table.homeDir') }}</th><th>{{ t('admin.table.status') }}</th></tr></thead>
|
<thead><tr><th>{{ t('admin.table.username') }}</th><th>{{ t('admin.table.homeDir') }}</th><th>{{ t('admin.table.status') }}</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -149,6 +167,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||||
@@ -604,6 +623,23 @@ import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
|||||||
import { apiCall } from '../api'
|
import { apiCall } from '../api'
|
||||||
import { loadSystemStats, systemStatsCache, SystemStats } from '../store'
|
import { loadSystemStats, systemStatsCache, SystemStats } from '../store'
|
||||||
import { getApiBase } from '@/api/config'
|
import { getApiBase } from '@/api/config'
|
||||||
|
import { useAdminAuth } from '@/composables/useAdminAuth'
|
||||||
|
|
||||||
|
const adminAuth = useAdminAuth()
|
||||||
|
const loginUsername = ref('demo')
|
||||||
|
const loginPassword = ref('demo123')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
const success = await adminAuth.login(loginUsername.value, loginPassword.value)
|
||||||
|
if (success) {
|
||||||
|
await loadUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
adminAuth.logout()
|
||||||
|
users.value = []
|
||||||
|
}
|
||||||
|
|
||||||
const activeTab = ref('dashboard')
|
const activeTab = ref('dashboard')
|
||||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||||
@@ -1025,11 +1061,28 @@ async function loadDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
try {
|
||||||
|
const base = getApiBase()
|
||||||
|
const resp = await fetch(`${base}/api/v2/admin/users`, {
|
||||||
|
headers: adminAuth.getAuthHeader()
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
users.value = data.users || []
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load users:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShares() {
|
async function loadShares() {
|
||||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
try {
|
||||||
|
const base = getApiBase()
|
||||||
|
const resp = await fetch(`${base}/api/v2/admin/shares`)
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
shares.value = await resp.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load shares:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@@ -1383,6 +1436,16 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.admin-table { width: 100%; border-collapse: collapse; }
|
.admin-table { width: 100%; border-collapse: collapse; }
|
||||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
|
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
|
||||||
|
.admin-login-form { background: var(--hover-background); padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
||||||
|
.admin-login-form h3 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.login-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.login-input { flex: 1; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background: var(--card-background); color: var(--text-primary); }
|
||||||
|
.login-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||||
|
.login-btn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.login-error { color: #ea4335; margin-top: 8px; font-size: 13px; }
|
||||||
|
.login-hint { color: var(--text-secondary); margin-top: 8px; font-size: 12px; }
|
||||||
|
.users-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.logout-btn { padding: 6px 12px; background: #ea4335; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||||
.settings-panel { max-width: 600px; }
|
.settings-panel { max-width: 600px; }
|
||||||
.settings-group { margin-bottom: 24px; }
|
.settings-group { margin-bottom: 24px; }
|
||||||
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
|
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
|
||||||
|
|||||||
Reference in New Issue
Block a user