mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
First-run admin setup wizard, password persistence, forced setup gate
- Admin enabled by default (was opt-in via env var) - New /admin/status (public) and /admin/setup (first-run only) endpoints - Setup endpoint locked after first use (returns 403) - Admin password persisted to SQLite config table (survives restarts) - Change password now persists to DB (was in-memory only) - Frontend router guard forces /admin redirect until setup is complete - AdminSetup.vue wizard: username + password + confirm - Public config exposes admin_enabled/admin_setup_complete for frontend - TLS warning only fires when password is actually configured
This commit is contained in:
parent
b86366116a
commit
1592407658
12 changed files with 375 additions and 8 deletions
|
|
@ -82,7 +82,7 @@ class UIConfig(BaseModel):
|
||||||
class AdminConfig(BaseModel):
|
class AdminConfig(BaseModel):
|
||||||
"""Admin panel settings."""
|
"""Admin panel settings."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = True
|
||||||
username: str = "admin"
|
username: str = "admin"
|
||||||
password_hash: str = ""
|
password_hash: str = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
|
||||||
logger.info("Config loaded from defaults + env vars (no YAML file)")
|
logger.info("Config loaded from defaults + env vars (no YAML file)")
|
||||||
|
|
||||||
# --- TLS warning ---
|
# --- TLS warning ---
|
||||||
if config.admin.enabled:
|
if config.admin.enabled and config.admin.password_hash:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy "
|
"Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy "
|
||||||
"(Caddy, Traefik, nginx) to protect admin credentials in transit."
|
"(Caddy, Traefik, nginx) to protect admin credentials in transit."
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""Admin API endpoints — protected by require_admin dependency.
|
"""Admin API endpoints — protected by require_admin dependency.
|
||||||
|
|
||||||
Settings are persisted to SQLite and survive container restarts.
|
Settings are persisted to SQLite and survive container restarts.
|
||||||
|
Admin setup (first-run password creation) is unauthenticated but only
|
||||||
|
available when no password has been configured yet.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
@ -17,6 +20,83 @@ logger = logging.getLogger("mediarip.admin")
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public endpoints (no auth) — admin status + first-run setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def admin_status(request: Request) -> dict:
|
||||||
|
"""Public endpoint: is admin enabled, and has initial setup been done?"""
|
||||||
|
config = request.app.state.config
|
||||||
|
return {
|
||||||
|
"enabled": config.admin.enabled,
|
||||||
|
"setup_complete": bool(config.admin.password_hash),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
async def admin_setup(request: Request) -> dict:
|
||||||
|
"""First-run setup: create admin credentials.
|
||||||
|
|
||||||
|
Only works when admin is enabled AND no password has been set yet.
|
||||||
|
After setup, this endpoint returns 403 — use /admin/password to change.
|
||||||
|
"""
|
||||||
|
config = request.app.state.config
|
||||||
|
|
||||||
|
if not config.admin.enabled:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"detail": "Admin panel is not enabled"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.admin.password_hash:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Admin is already configured. Use the change password flow."},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
username = body.get("username", "").strip()
|
||||||
|
password = body.get("password", "")
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"detail": "Username is required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(password) < 4:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"detail": "Password must be at least 4 characters"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash and persist
|
||||||
|
password_hash = bcrypt.hashpw(
|
||||||
|
password.encode("utf-8"), bcrypt.gensalt()
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
config.admin.username = username
|
||||||
|
config.admin.password_hash = password_hash
|
||||||
|
|
||||||
|
# Persist to DB so it survives restarts
|
||||||
|
from app.services.settings import save_settings
|
||||||
|
db = request.app.state.db
|
||||||
|
await save_settings(db, {
|
||||||
|
"admin_username": username,
|
||||||
|
"admin_password_hash": password_hash,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("Admin setup complete — user '%s' created", username)
|
||||||
|
return {"status": "ok", "username": username}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authenticated endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions")
|
@router.get("/sessions")
|
||||||
async def list_sessions(
|
async def list_sessions(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -351,9 +431,7 @@ async def change_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
_admin: str = Depends(require_admin),
|
_admin: str = Depends(require_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Change admin password. Persisted in-memory only (set via env var for persistence)."""
|
"""Change admin password. Persisted to SQLite for durability."""
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
current = body.get("current_password", "")
|
current = body.get("current_password", "")
|
||||||
new_pw = body.get("new_password", "")
|
new_pw = body.get("new_password", "")
|
||||||
|
|
@ -387,8 +465,13 @@ async def change_password(
|
||||||
|
|
||||||
new_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
new_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
config.admin.password_hash = new_hash
|
config.admin.password_hash = new_hash
|
||||||
logger.info("Admin password changed by user '%s'", _admin)
|
|
||||||
|
|
||||||
|
# Persist to DB
|
||||||
|
from app.services.settings import save_settings
|
||||||
|
db = request.app.state.db
|
||||||
|
await save_settings(db, {"admin_password_hash": new_hash})
|
||||||
|
|
||||||
|
logger.info("Admin password changed by user '%s'", _admin)
|
||||||
return {"status": "ok", "message": "Password changed successfully"}
|
return {"status": "ok", "message": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,6 @@ async def public_config(request: Request) -> dict:
|
||||||
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
|
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
|
||||||
"privacy_mode": config.purge.privacy_mode,
|
"privacy_mode": config.purge.privacy_mode,
|
||||||
"privacy_retention_hours": config.purge.privacy_retention_hours,
|
"privacy_retention_hours": config.purge.privacy_retention_hours,
|
||||||
|
"admin_enabled": config.admin.enabled,
|
||||||
|
"admin_setup_complete": bool(config.admin.password_hash),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ ADMIN_WRITABLE_KEYS = {
|
||||||
"session_mode",
|
"session_mode",
|
||||||
"session_timeout_hours",
|
"session_timeout_hours",
|
||||||
"admin_username",
|
"admin_username",
|
||||||
|
"admin_password_hash",
|
||||||
"purge_enabled",
|
"purge_enabled",
|
||||||
"purge_max_age_hours",
|
"purge_max_age_hours",
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +100,8 @@ def apply_persisted_to_config(config, settings: dict) -> None:
|
||||||
config.session.timeout_hours = settings["session_timeout_hours"]
|
config.session.timeout_hours = settings["session_timeout_hours"]
|
||||||
if "admin_username" in settings:
|
if "admin_username" in settings:
|
||||||
config.admin.username = settings["admin_username"]
|
config.admin.username = settings["admin_username"]
|
||||||
|
if "admin_password_hash" in settings:
|
||||||
|
config.admin.password_hash = settings["admin_password_hash"]
|
||||||
if "purge_enabled" in settings:
|
if "purge_enabled" in settings:
|
||||||
config.purge.enabled = settings["purge_enabled"]
|
config.purge.enabled = settings["purge_enabled"]
|
||||||
if "purge_max_age_hours" in settings:
|
if "purge_max_age_hours" in settings:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ class TestZeroConfig:
|
||||||
|
|
||||||
def test_admin_defaults(self):
|
def test_admin_defaults(self):
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
assert config.admin.enabled is False
|
assert config.admin.enabled is True
|
||||||
|
assert config.admin.username == "admin"
|
||||||
|
assert config.admin.password_hash == ""
|
||||||
|
|
||||||
def test_source_templates_default_entries(self):
|
def test_source_templates_default_entries(self):
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ export interface PublicConfig {
|
||||||
default_audio_format: string
|
default_audio_format: string
|
||||||
privacy_mode: boolean
|
privacy_mode: boolean
|
||||||
privacy_retention_hours: number
|
privacy_retention_hours: number
|
||||||
|
admin_enabled: boolean
|
||||||
|
admin_setup_complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useAdminStore } from '@/stores/admin'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import AdminLogin from './AdminLogin.vue'
|
import AdminLogin from './AdminLogin.vue'
|
||||||
|
import AdminSetup from './AdminSetup.vue'
|
||||||
|
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
@ -194,7 +195,16 @@ function formatFilesize(bytes: number | null): string {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-panel">
|
<div class="admin-panel">
|
||||||
<AdminLogin v-if="!store.isAuthenticated" />
|
<!-- First-run setup: no password configured yet -->
|
||||||
|
<AdminSetup v-if="configStore.config?.admin_enabled && !configStore.config?.admin_setup_complete" />
|
||||||
|
|
||||||
|
<!-- Admin disabled -->
|
||||||
|
<div v-else-if="!configStore.config?.admin_enabled" class="admin-disabled">
|
||||||
|
<p>Admin panel is disabled. Set <code>admin.enabled: true</code> in your config to enable it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal login/panel flow -->
|
||||||
|
<AdminLogin v-else-if="!store.isAuthenticated" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
|
|
@ -1112,4 +1122,22 @@ h3 {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-disabled {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: var(--space-xl) auto;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-disabled code {
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
186
frontend/src/components/AdminSetup.vue
Normal file
186
frontend/src/components/AdminSetup.vue
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useAdminStore } from '@/stores/admin'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { clearAdminStatusCache } from '@/router'
|
||||||
|
|
||||||
|
const store = useAdminStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const username = ref('admin')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const passwordMismatch = computed(() =>
|
||||||
|
confirmPassword.value.length > 0 && password.value !== confirmPassword.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
username.value.trim().length > 0 &&
|
||||||
|
password.value.length >= 4 &&
|
||||||
|
password.value === confirmPassword.value &&
|
||||||
|
!isSubmitting.value
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSetup() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
const ok = await store.setup(username.value.trim(), password.value)
|
||||||
|
if (ok) {
|
||||||
|
// Clear cached status so router guard knows setup is done
|
||||||
|
clearAdminStatusCache()
|
||||||
|
// Reload public config so admin_setup_complete updates everywhere
|
||||||
|
await configStore.loadConfig()
|
||||||
|
}
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-setup">
|
||||||
|
<div class="setup-card">
|
||||||
|
<h2>Set Up Admin</h2>
|
||||||
|
<p class="setup-hint">
|
||||||
|
Create your admin account. You'll use these credentials to access
|
||||||
|
the admin panel for managing sessions, settings, and downloads.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSetup" class="setup-form">
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="admin"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 4 characters"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Confirm Password</span>
|
||||||
|
<input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:class="{ 'input-error': passwordMismatch }"
|
||||||
|
/>
|
||||||
|
<span v-if="passwordMismatch" class="field-error">Passwords don't match</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="!canSubmit">
|
||||||
|
{{ isSubmitting ? 'Creating…' : 'Create Admin Account' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="store.authError" class="error">{{ store.authError }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-setup {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: var(--space-xl) auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
label > span {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.input-error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -16,4 +16,41 @@ const router = createRouter({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cache the admin status check — fetched once per page load
|
||||||
|
let adminStatusCache: { enabled: boolean; setup_complete: boolean } | null = null
|
||||||
|
|
||||||
|
async function fetchAdminStatus() {
|
||||||
|
if (adminStatusCache) return adminStatusCache
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/status')
|
||||||
|
if (res.ok) {
|
||||||
|
adminStatusCache = await res.json()
|
||||||
|
return adminStatusCache
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — let navigation proceed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the cached admin status — call after setup completes. */
|
||||||
|
export function clearAdminStatusCache() {
|
||||||
|
adminStatusCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
// Skip if already heading to admin
|
||||||
|
if (to.name === 'admin') return true
|
||||||
|
|
||||||
|
const status = await fetchAdminStatus()
|
||||||
|
if (!status) return true // Can't determine — let it through
|
||||||
|
|
||||||
|
// Force setup if admin is enabled but no password set
|
||||||
|
if (status.enabled && !status.setup_complete) {
|
||||||
|
return { name: 'admin' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,27 @@ export const useAdminStore = defineStore('admin', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setup(user: string, pass: string): Promise<boolean> {
|
||||||
|
authError.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password: pass }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
// Auto-login after setup
|
||||||
|
return await login(user, pass)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
authError.value = data.detail || `Setup failed: ${res.status}`
|
||||||
|
return false
|
||||||
|
} catch (err: any) {
|
||||||
|
authError.value = err.message || 'Network error'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout(): void {
|
function logout(): void {
|
||||||
username.value = ''
|
username.value = ''
|
||||||
password.value = ''
|
password.value = ''
|
||||||
|
|
@ -176,6 +197,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
errorLog,
|
errorLog,
|
||||||
login,
|
login,
|
||||||
|
setup,
|
||||||
logout,
|
logout,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
loadStorage,
|
loadStorage,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ describe('config store', () => {
|
||||||
default_audio_format: 'auto',
|
default_audio_format: 'auto',
|
||||||
privacy_mode: false,
|
privacy_mode: false,
|
||||||
privacy_retention_hours: 24,
|
privacy_retention_hours: 24,
|
||||||
|
admin_enabled: true,
|
||||||
|
admin_setup_complete: false,
|
||||||
}
|
}
|
||||||
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue