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:
xpltd 2026-03-21 20:01:13 -05:00
parent b86366116a
commit 1592407658
12 changed files with 375 additions and 8 deletions

View file

@ -82,7 +82,7 @@ class UIConfig(BaseModel):
class AdminConfig(BaseModel):
"""Admin panel settings."""
enabled: bool = False
enabled: bool = True
username: str = "admin"
password_hash: str = ""

View file

@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
logger.info("Config loaded from defaults + env vars (no YAML file)")
# --- TLS warning ---
if config.admin.enabled:
if config.admin.enabled and config.admin.password_hash:
logger.warning(
"Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy "
"(Caddy, Traefik, nginx) to protect admin credentials in transit."

View file

@ -1,12 +1,15 @@
"""Admin API endpoints — protected by require_admin dependency.
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
import logging
import bcrypt
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
@ -17,6 +20,83 @@ logger = logging.getLogger("mediarip.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")
async def list_sessions(
request: Request,
@ -351,9 +431,7 @@ async def change_password(
request: Request,
_admin: str = Depends(require_admin),
) -> dict:
"""Change admin password. Persisted in-memory only (set via env var for persistence)."""
import bcrypt
"""Change admin password. Persisted to SQLite for durability."""
body = await request.json()
current = body.get("current_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")
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"}

View file

@ -29,4 +29,6 @@ async def public_config(request: Request) -> dict:
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
"privacy_mode": config.purge.privacy_mode,
"privacy_retention_hours": config.purge.privacy_retention_hours,
"admin_enabled": config.admin.enabled,
"admin_setup_complete": bool(config.admin.password_hash),
}

View file

@ -31,6 +31,7 @@ ADMIN_WRITABLE_KEYS = {
"session_mode",
"session_timeout_hours",
"admin_username",
"admin_password_hash",
"purge_enabled",
"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"]
if "admin_username" in settings:
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:
config.purge.enabled = settings["purge_enabled"]
if "purge_max_age_hours" in settings:

View file

@ -29,7 +29,9 @@ class TestZeroConfig:
def test_admin_defaults(self):
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):
config = AppConfig()

View file

@ -77,6 +77,8 @@ export interface PublicConfig {
default_audio_format: string
privacy_mode: boolean
privacy_retention_hours: number
admin_enabled: boolean
admin_setup_complete: boolean
}
export interface HealthStatus {

View file

@ -5,6 +5,7 @@ import { useAdminStore } from '@/stores/admin'
import { useConfigStore } from '@/stores/config'
import { api } from '@/api/client'
import AdminLogin from './AdminLogin.vue'
import AdminSetup from './AdminSetup.vue'
const store = useAdminStore()
const configStore = useConfigStore()
@ -194,7 +195,16 @@ function formatFilesize(bytes: number | null): string {
<template>
<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>
<div class="admin-header">
@ -1112,4 +1122,22 @@ h3 {
font-size: var(--font-size-xs);
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>

View 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>

View file

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

View file

@ -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 {
username.value = ''
password.value = ''
@ -176,6 +197,7 @@ export const useAdminStore = defineStore('admin', () => {
isLoading,
errorLog,
login,
setup,
logout,
loadSessions,
loadStorage,

View file

@ -35,6 +35,8 @@ describe('config store', () => {
default_audio_format: 'auto',
privacy_mode: false,
privacy_retention_hours: 24,
admin_enabled: true,
admin_setup_complete: false,
}
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)