From 3b638f041689e5c92b8ce595d6dec086ec0bbb63 Mon Sep 17 00:00:00 2001 From: Momentry Studio Date: Tue, 27 Oct 2026 02:17:41 +0800 Subject: [PATCH] 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 --- data/users/demo.sqlite-shm | Bin 32768 -> 32768 bytes src/api/index.ts | 24 +++++++-- src/composables/useAdminAuth.ts | 76 +++++++++++++++++++++++++++++ src/views/AdminView.vue | 83 ++++++++++++++++++++++++++++---- 4 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 src/composables/useAdminAuth.ts diff --git a/data/users/demo.sqlite-shm b/data/users/demo.sqlite-shm index 056709c6e255224615a3baf05673d550ab96ffc5..a95cd29855499fa2b350585ff88ad1624eee713f 100644 GIT binary patch delta 57 vcmZo@U}|V!;*@x#%K!!wIpqb*?)|z}cP69=S&$7PI5AF%sfKZ5Q(Zj(_Hz?Q delta 57 wcmZo@U}|V!;*@x#%K!p06FKDtAKm|T?bNxDB4j}}h~UIHC8k7%jZJm+03iSr*#H0l diff --git a/src/api/index.ts b/src/api/index.ts index a91c5e7..ddee773 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -150,9 +150,23 @@ export class ApiError extends Error { async function httpCall(cmd: string, args: Record, retries = 3): Promise { 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}` + // Get admin token from localStorage if needed + const headers: Record = 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 timeout = setTimeout(() => controller.abort(), requestTimeout ?? DEFAULT_TIMEOUT) @@ -162,7 +176,7 @@ async function httpCall(cmd: string, args: Record, retries = 3): Pr try { response = await fetch(fullUrl, { method, - headers: body ? { 'Content-Type': 'application/json' } : {}, + headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }) @@ -238,7 +252,7 @@ async function httpCall(cmd: string, args: Record, retries = 3): Pr } } -function buildHttpRequest(cmd: string, args: Record): { url: string; method: string; body?: any; timeout?: number } { +function buildHttpRequest(cmd: string, args: Record): { url: string; method: string; body?: any; timeout?: number; requiresAdminToken?: boolean } { const a = args switch (cmd) { @@ -668,9 +682,11 @@ case 'search_keyword': { return { url, method: 'GET' } } 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': 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 --- case 'get_pose': { diff --git a/src/composables/useAdminAuth.ts b/src/composables/useAdminAuth.ts new file mode 100644 index 0000000..4e75428 --- /dev/null +++ b/src/composables/useAdminAuth.ts @@ -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(null) + const username = ref(null) + const expiresAt = ref(null) + const loading = ref(false) + const error = ref(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 { + 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 { + return token.value ? { 'Authorization': `Bearer ${token.value}` } : {} + } + + return { token, username, expiresAt, loading, error, login, logout, isLoggedIn, getAuthHeader } +} diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue index f50e2d1..de35e8f 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -141,14 +141,33 @@
- - - - - - - -
{{ t('admin.table.username') }}{{ t('admin.table.homeDir') }}{{ t('admin.table.status') }}
{{ u.username }}{{ u.homeDir }}{{ u.status === 1 ? 'Active' : 'Disabled' }}
+ + + + +
@@ -604,6 +623,23 @@ import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { apiCall } from '../api' import { loadSystemStats, systemStatsCache, SystemStats } from '../store' 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 stats = ref({ cpu: 0, memory: 0, disk: 0 }) @@ -1025,11 +1061,28 @@ async function loadDashboard() { } 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() { - 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() { @@ -1383,6 +1436,16 @@ onMounted(async () => { .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-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-group { margin-bottom: 24px; } .settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }