mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-06-02 10:44:30 -06:00
- No API key configured: external API access blocked, browser-only - API key generated: external access enabled with that key - Added 'Sure?' confirmation on Regenerate and Revoke buttons (3s timeout) - Updated hint text to reflect security-first default
1340 lines
38 KiB
Vue
1340 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
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()
|
||
const router = useRouter()
|
||
const activeTab = ref<'sessions' | 'storage' | 'errors' | 'settings'>('sessions')
|
||
|
||
// Session expansion state
|
||
const expandedSessions = ref<Set<string>>(new Set())
|
||
const sessionJobs = ref<Record<string, any[]>>({})
|
||
const loadingJobs = ref<Set<string>>(new Set())
|
||
|
||
// Settings state
|
||
const welcomeMessage = ref('')
|
||
const defaultVideoFormat = ref('auto')
|
||
const defaultAudioFormat = ref('auto')
|
||
const settingsSaved = ref(false)
|
||
const privacyMode = ref(false)
|
||
const privacyRetentionMinutes = ref(1440)
|
||
const purgeConfirming = ref(false)
|
||
let purgeConfirmTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// New persisted settings
|
||
const maxConcurrent = ref(3)
|
||
const sessionMode = ref('isolated')
|
||
const sessionTimeoutHours = ref(72)
|
||
const adminUsername = ref('admin')
|
||
const purgeEnabled = ref(true)
|
||
const purgeMaxAgeMinutes = ref(1440)
|
||
|
||
// Change password state
|
||
const currentPassword = ref('')
|
||
const newPassword = ref('')
|
||
const confirmPassword = ref('')
|
||
const changingPassword = ref(false)
|
||
const passwordChanged = ref(false)
|
||
const passwordError = ref<string | null>(null)
|
||
|
||
// API key state
|
||
const apiKey = ref<string | null>(null)
|
||
const showApiKey = ref(false)
|
||
const apiKeyCopied = ref(false)
|
||
const regenConfirming = ref(false)
|
||
const revokeConfirming = ref(false)
|
||
let regenConfirmTimer: ReturnType<typeof setTimeout> | null = null
|
||
let revokeConfirmTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const canChangePassword = computed(() =>
|
||
currentPassword.value.length > 0 &&
|
||
newPassword.value.length >= 4 &&
|
||
newPassword.value === confirmPassword.value
|
||
)
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||
}
|
||
|
||
async function switchTab(tab: typeof activeTab.value) {
|
||
activeTab.value = tab
|
||
settingsSaved.value = false
|
||
if (tab === 'sessions') await store.loadSessions()
|
||
if (tab === 'storage') await store.loadStorage()
|
||
if (tab === 'errors') await store.loadErrorLog()
|
||
if (tab === 'settings') {
|
||
try {
|
||
const res = await fetch('/api/admin/settings', {
|
||
headers: { Authorization: `Basic ${btoa(`${store.username}:${store.password}`)}` },
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
welcomeMessage.value = data.welcome_message ?? ''
|
||
defaultVideoFormat.value = data.default_video_format || 'auto'
|
||
defaultAudioFormat.value = data.default_audio_format || 'auto'
|
||
privacyMode.value = data.privacy_mode ?? false
|
||
privacyRetentionMinutes.value = data.privacy_retention_minutes ?? 1440
|
||
maxConcurrent.value = data.max_concurrent ?? 3
|
||
sessionMode.value = data.session_mode ?? 'isolated'
|
||
sessionTimeoutHours.value = data.session_timeout_hours ?? 72
|
||
adminUsername.value = data.admin_username ?? 'admin'
|
||
purgeEnabled.value = data.purge_enabled ?? false
|
||
purgeMaxAgeMinutes.value = data.purge_max_age_minutes ?? 1440
|
||
}
|
||
} catch {
|
||
// Keep current values
|
||
}
|
||
await loadApiKey()
|
||
}
|
||
}
|
||
|
||
async function saveAllSettings() {
|
||
settingsSaved.value = false
|
||
const ok = await store.updateSettings({
|
||
welcome_message: welcomeMessage.value,
|
||
default_video_format: defaultVideoFormat.value,
|
||
default_audio_format: defaultAudioFormat.value,
|
||
privacy_mode: privacyMode.value,
|
||
privacy_retention_minutes: privacyRetentionMinutes.value,
|
||
max_concurrent: maxConcurrent.value,
|
||
session_mode: sessionMode.value,
|
||
session_timeout_hours: sessionTimeoutHours.value,
|
||
admin_username: adminUsername.value,
|
||
purge_enabled: purgeEnabled.value,
|
||
purge_max_age_minutes: purgeMaxAgeMinutes.value,
|
||
})
|
||
if (ok) {
|
||
await configStore.loadConfig()
|
||
settingsSaved.value = true
|
||
setTimeout(() => { settingsSaved.value = false }, 3000)
|
||
}
|
||
}
|
||
|
||
function handlePurgeClick() {
|
||
if (purgeConfirming.value) {
|
||
purgeConfirming.value = false
|
||
if (purgeConfirmTimer) clearTimeout(purgeConfirmTimer)
|
||
store.triggerPurge()
|
||
} else {
|
||
purgeConfirming.value = true
|
||
purgeConfirmTimer = setTimeout(() => { purgeConfirming.value = false }, 3000)
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
if (!canChangePassword.value) return
|
||
changingPassword.value = true
|
||
passwordChanged.value = false
|
||
passwordError.value = null
|
||
|
||
try {
|
||
const res = await fetch('/api/admin/password', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': 'Basic ' + btoa(store.username + ':' + store.password),
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
current_password: currentPassword.value,
|
||
new_password: newPassword.value,
|
||
}),
|
||
})
|
||
|
||
if (res.ok) {
|
||
passwordChanged.value = true
|
||
currentPassword.value = ''
|
||
newPassword.value = ''
|
||
confirmPassword.value = ''
|
||
// Auto-logout after 1.5s so user sees the success message
|
||
setTimeout(() => {
|
||
store.logout()
|
||
router.push('/')
|
||
}, 1500)
|
||
} else {
|
||
const data = await res.json()
|
||
passwordError.value = data.detail || 'Failed to change password'
|
||
}
|
||
} catch {
|
||
passwordError.value = 'Network error'
|
||
} finally {
|
||
changingPassword.value = false
|
||
}
|
||
}
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
return { 'Authorization': 'Basic ' + btoa(store.username + ':' + store.password) }
|
||
}
|
||
|
||
async function loadApiKey() {
|
||
try {
|
||
const res = await fetch('/api/admin/api-key', { headers: authHeaders() })
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
apiKey.value = data.api_key
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function generateApiKey() {
|
||
try {
|
||
const res = await fetch('/api/admin/api-key', { method: 'POST', headers: authHeaders() })
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
apiKey.value = data.api_key
|
||
showApiKey.value = true
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function regenerateApiKey() {
|
||
await generateApiKey()
|
||
regenConfirming.value = false
|
||
}
|
||
|
||
async function revokeApiKey() {
|
||
try {
|
||
const res = await fetch('/api/admin/api-key', { method: 'DELETE', headers: authHeaders() })
|
||
if (res.ok) {
|
||
apiKey.value = null
|
||
showApiKey.value = false
|
||
}
|
||
} catch { /* ignore */ }
|
||
revokeConfirming.value = false
|
||
}
|
||
|
||
function handleRegenClick() {
|
||
if (regenConfirming.value) {
|
||
regenerateApiKey()
|
||
return
|
||
}
|
||
regenConfirming.value = true
|
||
if (regenConfirmTimer) clearTimeout(regenConfirmTimer)
|
||
regenConfirmTimer = setTimeout(() => { regenConfirming.value = false }, 3000)
|
||
}
|
||
|
||
function handleRevokeClick() {
|
||
if (revokeConfirming.value) {
|
||
revokeApiKey()
|
||
return
|
||
}
|
||
revokeConfirming.value = true
|
||
if (revokeConfirmTimer) clearTimeout(revokeConfirmTimer)
|
||
revokeConfirmTimer = setTimeout(() => { revokeConfirming.value = false }, 3000)
|
||
}
|
||
|
||
function copyApiKey() {
|
||
if (apiKey.value) {
|
||
navigator.clipboard.writeText(apiKey.value)
|
||
apiKeyCopied.value = true
|
||
setTimeout(() => { apiKeyCopied.value = false }, 2000)
|
||
}
|
||
}
|
||
|
||
async function toggleSession(sessionId: string) {
|
||
if (expandedSessions.value.has(sessionId)) {
|
||
expandedSessions.value.delete(sessionId)
|
||
return
|
||
}
|
||
expandedSessions.value.add(sessionId)
|
||
if (!sessionJobs.value[sessionId]) {
|
||
loadingJobs.value.add(sessionId)
|
||
try {
|
||
const res = await fetch(`/api/admin/sessions/${sessionId}/jobs`, {
|
||
headers: { Authorization: `Basic ${btoa(`${store.username}:${store.password}`)}` },
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
sessionJobs.value[sessionId] = data.jobs
|
||
}
|
||
} catch { /* ignore */ }
|
||
loadingJobs.value.delete(sessionId)
|
||
}
|
||
}
|
||
|
||
function jobFilename(job: any): string {
|
||
if (!job.filename) return '—'
|
||
const parts = job.filename.replace(/\\/g, '/').split('/')
|
||
return parts[parts.length - 1]
|
||
}
|
||
|
||
function formatFilesize(bytes: number | null): string {
|
||
if (!bytes) return '—'
|
||
return formatBytes(bytes)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="admin-panel">
|
||
<!-- 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">
|
||
<h2>Admin Panel</h2>
|
||
<button class="btn-logout" @click="store.logout(); router.push('/')">Logout</button>
|
||
</div>
|
||
|
||
<div class="admin-tabs">
|
||
<button
|
||
v-for="tab in (['sessions', 'storage', 'errors', 'settings'] as const)"
|
||
:key="tab"
|
||
:class="{ active: activeTab === tab }"
|
||
@click="switchTab(tab)"
|
||
>
|
||
{{ tab }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Sessions tab -->
|
||
<div v-if="activeTab === 'sessions'" class="tab-content">
|
||
<table class="admin-table" v-if="store.sessions.length">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Session ID</th>
|
||
<th>Last Seen</th>
|
||
<th>Jobs</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="s in store.sessions" :key="s.id">
|
||
<tr
|
||
class="session-row"
|
||
:class="{ expanded: expandedSessions.has(s.id), clickable: s.job_count > 0 }"
|
||
@click="s.job_count > 0 && toggleSession(s.id)"
|
||
>
|
||
<td class="col-expand">
|
||
<span v-if="s.job_count > 0" class="expand-icon">{{ expandedSessions.has(s.id) ? '▼' : '▶' }}</span>
|
||
</td>
|
||
<td class="mono">{{ s.id.slice(0, 8) }}…</td>
|
||
<td>{{ new Date(s.last_seen).toLocaleString() }}</td>
|
||
<td>{{ s.job_count }}</td>
|
||
</tr>
|
||
<tr v-if="expandedSessions.has(s.id)" class="jobs-detail-row">
|
||
<td colspan="4">
|
||
<div v-if="loadingJobs.has(s.id)" class="jobs-loading">Loading…</div>
|
||
<div v-else-if="sessionJobs[s.id]?.length" class="jobs-detail">
|
||
<div v-for="job in sessionJobs[s.id]" :key="job.id" class="job-item">
|
||
<span class="job-filename">{{ jobFilename(job) }}</span>
|
||
<span class="job-size">{{ formatFilesize(job.filesize) }}</span>
|
||
<span class="job-status badge-sm" :class="'badge-' + job.status">{{ job.status }}</span>
|
||
<span class="job-time">{{ new Date(job.created_at).toLocaleString() }}</span>
|
||
<a v-if="job.url" class="job-url" :href="job.url" target="_blank" rel="noopener" :title="job.url">↗</a>
|
||
</div>
|
||
</div>
|
||
<div v-else class="jobs-empty">No jobs found.</div>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="empty">No sessions found.</p>
|
||
</div>
|
||
|
||
<!-- Storage tab -->
|
||
<div v-if="activeTab === 'storage'" class="tab-content">
|
||
<div v-if="store.storage" class="storage-info">
|
||
<div class="stat">
|
||
<span class="stat-label">Total</span>
|
||
<span class="stat-value">{{ formatBytes(store.storage.disk.total) }}</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">Used</span>
|
||
<span class="stat-value">{{ formatBytes(store.storage.disk.used) }}</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">Free</span>
|
||
<span class="stat-value">{{ formatBytes(store.storage.disk.free) }}</span>
|
||
</div>
|
||
<h3>Jobs by Status</h3>
|
||
<div v-for="(count, status) in store.storage.jobs_by_status" :key="status" class="stat">
|
||
<span class="stat-label">{{ status }}</span>
|
||
<span class="stat-value">{{ count }}</span>
|
||
</div>
|
||
</div>
|
||
<p v-else class="empty">Loading…</p>
|
||
</div>
|
||
|
||
<!-- Errors tab -->
|
||
<div v-if="activeTab === 'errors'" class="tab-content">
|
||
<div class="error-log-header">
|
||
<p class="field-hint" style="margin: 0;">
|
||
Failed downloads are logged here with enough detail to diagnose domain-specific issues.
|
||
</p>
|
||
<button
|
||
v-if="store.errorLog.length"
|
||
@click="store.clearErrorLog()"
|
||
:disabled="store.isLoading"
|
||
class="btn-clear-log"
|
||
>
|
||
Clear Log
|
||
</button>
|
||
</div>
|
||
<div v-if="store.errorLog.length" class="error-log">
|
||
<div v-for="entry in store.errorLog" :key="entry.id" class="error-entry">
|
||
<div class="error-entry-header">
|
||
<span class="error-domain">{{ entry.domain }}</span>
|
||
<span class="error-time">{{ new Date(entry.created_at).toLocaleString() }}</span>
|
||
</div>
|
||
<div class="error-url mono">{{ entry.url }}</div>
|
||
<div class="error-message">{{ entry.error }}</div>
|
||
<div v-if="entry.format_id || entry.media_type" class="error-meta">
|
||
<span v-if="entry.media_type">{{ entry.media_type }}</span>
|
||
<span v-if="entry.format_id">format: {{ entry.format_id }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-else class="empty">No errors logged.</p>
|
||
</div>
|
||
|
||
<!-- Settings tab -->
|
||
<div v-if="activeTab === 'settings'" class="tab-content">
|
||
<!-- All configurable settings in one form -->
|
||
<div class="settings-form">
|
||
<div class="settings-field">
|
||
<label for="welcome-msg">Welcome Message</label>
|
||
<p class="field-hint">Displayed above the URL input on the main page. Leave empty to hide.</p>
|
||
<textarea
|
||
id="welcome-msg"
|
||
v-model="welcomeMessage"
|
||
rows="3"
|
||
class="settings-textarea"
|
||
placeholder="Enter a welcome message…"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label>Default Output Formats</label>
|
||
<p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
|
||
<div class="format-defaults">
|
||
<div class="format-default-row">
|
||
<span class="format-default-label">Video</span>
|
||
<select v-model="defaultVideoFormat" class="settings-select">
|
||
<option value="auto">Auto (native container)</option>
|
||
<option value="mp4">MP4</option>
|
||
<option value="webm">WebM</option>
|
||
</select>
|
||
</div>
|
||
<div class="format-default-row">
|
||
<span class="format-default-label">Audio</span>
|
||
<select v-model="defaultAudioFormat" class="settings-select">
|
||
<option value="auto">Auto (native container)</option>
|
||
<option value="mp3">MP3</option>
|
||
<option value="m4a">M4A (AAC)</option>
|
||
<option value="flac">FLAC</option>
|
||
<option value="wav">WAV</option>
|
||
<option value="opus">Opus</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label class="toggle-label">
|
||
<span>Privacy Mode</span>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" v-model="privacyMode" />
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</label>
|
||
<p class="field-hint">
|
||
Automatically purge download history, files, and session data
|
||
after the retention period.
|
||
</p>
|
||
<div v-if="privacyMode" class="retention-setting">
|
||
<label class="retention-label">Retention period</label>
|
||
<div class="retention-input-row">
|
||
<input
|
||
type="number"
|
||
v-model.number="privacyRetentionMinutes"
|
||
min="1"
|
||
max="525600"
|
||
class="settings-input retention-input"
|
||
/>
|
||
<span class="retention-unit">minutes</span>
|
||
</div>
|
||
<p class="field-hint">
|
||
Data older than this is automatically purged (default: 1440 min / 24 hours).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Server settings -->
|
||
<div class="settings-field">
|
||
<label>Max Concurrent Downloads</label>
|
||
<p class="field-hint">How many downloads can run in parallel (1–10).</p>
|
||
<input
|
||
type="number"
|
||
v-model.number="maxConcurrent"
|
||
min="1"
|
||
max="10"
|
||
class="settings-input"
|
||
style="width: 80px;"
|
||
/>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label>Session Mode</label>
|
||
<p class="field-hint">Controls download queue visibility between browser sessions.</p>
|
||
<select v-model="sessionMode" class="settings-select">
|
||
<option value="isolated">Isolated — each browser has its own queue</option>
|
||
<option value="shared">Shared — all users see all downloads</option>
|
||
<option value="open">Open — no session tracking</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label>Session Timeout</label>
|
||
<p class="field-hint">Hours before an inactive session cookie expires (1–8760).</p>
|
||
<div class="retention-input-row">
|
||
<input
|
||
type="number"
|
||
v-model.number="sessionTimeoutHours"
|
||
min="1"
|
||
max="8760"
|
||
class="settings-input retention-input"
|
||
/>
|
||
<span class="retention-unit">hours</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label>Admin Username</label>
|
||
<p class="field-hint">Username for admin panel login.</p>
|
||
<input
|
||
type="text"
|
||
v-model="adminUsername"
|
||
class="settings-input"
|
||
style="max-width: 200px;"
|
||
autocomplete="username"
|
||
/>
|
||
</div>
|
||
|
||
<div class="settings-field">
|
||
<label class="toggle-label">
|
||
<span>Auto-Purge</span>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" v-model="purgeEnabled" />
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</label>
|
||
<p class="field-hint">
|
||
Automatically delete completed/failed downloads on a schedule.
|
||
</p>
|
||
<div v-if="purgeEnabled" class="retention-setting">
|
||
<label class="retention-label">Delete downloads older than</label>
|
||
<div class="retention-input-row">
|
||
<input
|
||
type="number"
|
||
v-model.number="purgeMaxAgeMinutes"
|
||
min="1"
|
||
max="5256000"
|
||
class="settings-input retention-input"
|
||
/>
|
||
<span class="retention-unit">minutes</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-actions settings-save-row">
|
||
<button @click="saveAllSettings" :disabled="store.isLoading" class="btn-save">
|
||
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
|
||
</button>
|
||
<span v-if="settingsSaved" class="save-confirm">✓ Saved</span>
|
||
</div>
|
||
<p class="field-hint">
|
||
Settings are saved to the database and persist across restarts.
|
||
</p>
|
||
</div>
|
||
|
||
<hr class="settings-divider" />
|
||
|
||
<!-- Actions (not "settings" — these are immediate operations) -->
|
||
<div class="settings-field">
|
||
<label>Manual Purge</label>
|
||
<p class="field-hint">
|
||
Immediately clear all completed and failed downloads — removes
|
||
database records, files from disk, and orphaned sessions.
|
||
Active downloads are never affected.
|
||
</p>
|
||
<button
|
||
@click="handlePurgeClick"
|
||
:disabled="store.isLoading"
|
||
class="btn-purge"
|
||
:class="{ 'btn-confirm': purgeConfirming }"
|
||
>
|
||
{{ store.isLoading ? 'Purging…' : purgeConfirming ? 'Sure?' : 'Run Purge Now' }}
|
||
</button>
|
||
<div v-if="store.purgeResult" class="purge-result">
|
||
<p>{{ store.purgeResult.rows_deleted }} jobs removed</p>
|
||
<p>{{ store.purgeResult.files_deleted }} files deleted</p>
|
||
<p v-if="store.purgeResult.sessions_deleted">{{ store.purgeResult.sessions_deleted }} sessions cleared</p>
|
||
<p v-if="store.purgeResult.active_skipped">{{ store.purgeResult.active_skipped }} active jobs skipped</p>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="settings-divider" />
|
||
|
||
<div class="settings-field">
|
||
<label>Change Password</label>
|
||
<p class="field-hint">Takes effect immediately. Set via MEDIARIP__ADMIN__PASSWORD_HASH env var for initial deployment.</p>
|
||
<div class="password-fields">
|
||
<input
|
||
v-model="currentPassword"
|
||
type="password"
|
||
placeholder="Current password"
|
||
autocomplete="current-password"
|
||
class="settings-input"
|
||
/>
|
||
<input
|
||
v-model="newPassword"
|
||
type="password"
|
||
placeholder="New password"
|
||
autocomplete="new-password"
|
||
class="settings-input"
|
||
/>
|
||
<input
|
||
v-model="confirmPassword"
|
||
type="password"
|
||
placeholder="Confirm new password"
|
||
autocomplete="new-password"
|
||
class="settings-input"
|
||
@keydown.enter="changePassword"
|
||
/>
|
||
<span
|
||
v-if="confirmPassword && newPassword && confirmPassword !== newPassword"
|
||
class="password-mismatch"
|
||
>
|
||
Passwords don't match
|
||
</span>
|
||
</div>
|
||
<div class="settings-actions" style="margin-top: var(--space-sm);">
|
||
<button
|
||
@click="changePassword"
|
||
:disabled="!canChangePassword || changingPassword"
|
||
class="btn-save"
|
||
>
|
||
{{ changingPassword ? 'Changing…' : 'Change Password' }}
|
||
</button>
|
||
<span v-if="passwordChanged" class="save-confirm">✓ Password changed</span>
|
||
<span v-if="passwordError" class="password-error">{{ passwordError }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="settings-divider" />
|
||
|
||
<!-- API Key management -->
|
||
<div class="settings-field">
|
||
<label>API Key</label>
|
||
<p class="field-hint">
|
||
Generate a key to enable external API access (e.g. scripts, automation).
|
||
Without a key, downloads can only be submitted through the web UI.
|
||
</p>
|
||
<div v-if="apiKey" class="api-key-display">
|
||
<div class="api-key-value">
|
||
<code class="mono api-key-text">{{ showApiKey ? apiKey : '•'.repeat(32) }}</code>
|
||
<button class="btn-icon" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide' : 'Show'">
|
||
<svg v-if="showApiKey" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
</button>
|
||
<button class="btn-icon" @click="copyApiKey" title="Copy to clipboard">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="api-key-actions">
|
||
<button
|
||
class="btn-regen"
|
||
:class="{ 'btn-confirm': regenConfirming }"
|
||
@click="handleRegenClick"
|
||
>
|
||
{{ regenConfirming ? 'Sure?' : 'Regenerate' }}
|
||
</button>
|
||
<button
|
||
class="btn-revoke"
|
||
:class="{ 'btn-confirm': revokeConfirming }"
|
||
@click="handleRevokeClick"
|
||
>
|
||
{{ revokeConfirming ? 'Sure?' : 'Revoke' }}
|
||
</button>
|
||
</div>
|
||
<span v-if="apiKeyCopied" class="save-confirm">✓ Copied</span>
|
||
</div>
|
||
<div v-else class="api-key-empty">
|
||
<p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set — external API access is disabled.</p>
|
||
<button class="btn-save" @click="generateApiKey">Generate API Key</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-panel {
|
||
max-width: var(--content-max-width);
|
||
margin: 0 auto;
|
||
padding: var(--space-lg) var(--space-md);
|
||
}
|
||
|
||
.admin-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.admin-header h2 {
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.btn-logout {
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.btn-logout:hover {
|
||
color: var(--color-error);
|
||
border-color: var(--color-error);
|
||
}
|
||
|
||
.admin-tabs {
|
||
display: flex;
|
||
gap: var(--space-xs);
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.admin-tabs button {
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--color-surface);
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.admin-tabs button.active {
|
||
color: var(--color-accent);
|
||
border-color: var(--color-accent);
|
||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||
}
|
||
|
||
.tab-content {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-lg);
|
||
}
|
||
|
||
.admin-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.admin-table th,
|
||
.admin-table td {
|
||
padding: var(--space-sm) var(--space-md);
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.admin-table th {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--font-size-sm);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.mono {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.storage-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
}
|
||
|
||
.stat {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: var(--space-xs) 0;
|
||
}
|
||
|
||
.stat-label {
|
||
color: var(--color-text-muted);
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.stat-value {
|
||
font-family: var(--font-mono);
|
||
}
|
||
|
||
h3 {
|
||
margin-top: var(--space-md);
|
||
margin-bottom: var(--space-sm);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--font-size-sm);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.btn-purge {
|
||
background: var(--color-warning);
|
||
color: var(--color-bg);
|
||
font-weight: 600;
|
||
margin-top: var(--space-md);
|
||
min-width: 130px;
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.btn-purge:hover:not(:disabled) {
|
||
background: var(--color-error);
|
||
}
|
||
|
||
.btn-purge.btn-confirm {
|
||
background: var(--color-error);
|
||
}
|
||
|
||
.purge-result {
|
||
margin-top: var(--space-md);
|
||
padding: var(--space-md);
|
||
background: var(--color-bg);
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.empty {
|
||
color: var(--color-text-muted);
|
||
text-align: center;
|
||
}
|
||
|
||
.settings-section {
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
|
||
.settings-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-lg);
|
||
}
|
||
|
||
.settings-form .settings-field {
|
||
padding-bottom: var(--space-md);
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.settings-form .settings-field:last-of-type {
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.settings-save-row {
|
||
padding-top: var(--space-sm);
|
||
}
|
||
|
||
.section-heading {
|
||
font-size: var(--font-size-md);
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
margin: 0 0 var(--space-md) 0;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.settings-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-xs);
|
||
}
|
||
|
||
.settings-field label {
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.field-hint {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
margin: 0;
|
||
}
|
||
|
||
.settings-textarea {
|
||
width: 100%;
|
||
padding: var(--space-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-ui);
|
||
font-size: var(--font-size-base);
|
||
resize: vertical;
|
||
}
|
||
|
||
.settings-textarea:focus {
|
||
outline: none;
|
||
border-color: var(--color-accent);
|
||
}
|
||
|
||
.settings-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
margin-top: var(--space-md);
|
||
}
|
||
|
||
.btn-save {
|
||
background: var(--color-accent);
|
||
color: var(--color-bg);
|
||
font-weight: 600;
|
||
padding: var(--space-sm) var(--space-lg);
|
||
}
|
||
|
||
.btn-save:hover:not(:disabled) {
|
||
background: var(--color-accent-hover);
|
||
}
|
||
|
||
.save-confirm {
|
||
color: var(--color-success);
|
||
font-weight: 500;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.format-defaults {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
margin-top: var(--space-sm);
|
||
}
|
||
|
||
.format-default-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.format-default-label {
|
||
min-width: 50px;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.settings-select {
|
||
padding: var(--space-xs) var(--space-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-ui);
|
||
font-size: var(--font-size-sm);
|
||
min-width: 200px;
|
||
}
|
||
|
||
.settings-select:focus {
|
||
outline: none;
|
||
border-color: var(--color-accent);
|
||
}
|
||
|
||
.settings-divider {
|
||
border: none;
|
||
border-top: 1px solid var(--color-border);
|
||
margin: var(--space-lg) 0;
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.toggle-label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.toggle-switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 44px;
|
||
height: 24px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.toggle-slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background-color: var(--color-border);
|
||
border-radius: 24px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.toggle-slider::before {
|
||
content: '';
|
||
position: absolute;
|
||
height: 18px;
|
||
width: 18px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background-color: var(--color-bg);
|
||
border-radius: 50%;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.toggle-switch input:checked + .toggle-slider {
|
||
background-color: var(--color-accent-primary, #00a8ff);
|
||
}
|
||
|
||
.toggle-switch input:checked + .toggle-slider::before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
/* Retention input */
|
||
.retention-setting {
|
||
margin-top: var(--space-sm);
|
||
padding-left: var(--space-sm);
|
||
border-left: 2px solid var(--color-accent-primary, #00a8ff);
|
||
}
|
||
|
||
.retention-label {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
margin-bottom: var(--space-xs);
|
||
}
|
||
|
||
.retention-input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
}
|
||
|
||
.retention-input {
|
||
width: 80px !important;
|
||
text-align: center;
|
||
}
|
||
|
||
.retention-unit {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.password-fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
margin-top: var(--space-sm);
|
||
max-width: 320px;
|
||
}
|
||
|
||
.settings-input {
|
||
padding: var(--space-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-ui);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.settings-input:focus {
|
||
outline: none;
|
||
border-color: var(--color-accent);
|
||
}
|
||
|
||
.password-error {
|
||
color: var(--color-error);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.password-mismatch {
|
||
color: var(--color-warning);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
/* Expandable session rows */
|
||
.session-row.clickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.session-row.clickable:hover {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
.session-row.expanded {
|
||
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
||
}
|
||
|
||
.col-expand {
|
||
width: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.expand-icon {
|
||
font-size: 10px;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.jobs-detail-row td {
|
||
padding: 0 var(--space-md) var(--space-md);
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.jobs-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: var(--space-sm) 0;
|
||
}
|
||
|
||
.job-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
padding: var(--space-xs) var(--space-sm);
|
||
font-size: var(--font-size-sm);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.job-filename {
|
||
flex: 1;
|
||
font-family: var(--font-mono);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.job-size {
|
||
font-family: var(--font-mono);
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.badge-sm {
|
||
font-size: 10px;
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||
.badge-failed { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
|
||
.badge-downloading { background: color-mix(in srgb, var(--color-accent) 15%, transparent); color: var(--color-accent); }
|
||
.badge-queued { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
|
||
|
||
.job-time {
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.job-url {
|
||
color: var(--color-accent);
|
||
text-decoration: none;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.job-url:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.jobs-loading, .jobs-empty {
|
||
padding: var(--space-sm);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--font-size-sm);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Error log */
|
||
.error-log-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-md);
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
|
||
.btn-clear-log {
|
||
background: var(--color-bg-elevated);
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
padding: var(--space-xs) var(--space-md);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-sm);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-clear-log:hover {
|
||
color: var(--color-error);
|
||
border-color: var(--color-error);
|
||
}
|
||
|
||
.error-log {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
}
|
||
|
||
.error-entry {
|
||
background: var(--color-bg-elevated);
|
||
border: 1px solid var(--color-border);
|
||
border-left: 3px solid var(--color-error);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--space-sm) var(--space-md);
|
||
}
|
||
|
||
.error-entry-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--space-xs);
|
||
}
|
||
|
||
.error-domain {
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.error-time {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.error-url {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
word-break: break-all;
|
||
margin-bottom: var(--space-xs);
|
||
}
|
||
|
||
.error-message {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-error);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.error-meta {
|
||
display: flex;
|
||
gap: var(--space-md);
|
||
margin-top: var(--space-xs);
|
||
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);
|
||
}
|
||
|
||
/* API Key */
|
||
.api-key-display {
|
||
margin-top: var(--space-sm);
|
||
}
|
||
|
||
.api-key-value {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-xs);
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
|
||
.api-key-text {
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--space-xs) var(--space-sm);
|
||
font-size: var(--font-size-sm);
|
||
word-break: break-all;
|
||
flex: 1;
|
||
max-width: 420px;
|
||
}
|
||
|
||
.btn-icon {
|
||
background: transparent;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-text-muted);
|
||
padding: 6px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.btn-icon:hover {
|
||
color: var(--color-text);
|
||
border-color: var(--color-text-muted);
|
||
}
|
||
|
||
.api-key-actions {
|
||
display: flex;
|
||
gap: var(--space-sm);
|
||
}
|
||
|
||
.btn-regen {
|
||
background: transparent;
|
||
color: var(--color-accent);
|
||
border: 1px solid var(--color-accent);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--space-xs) var(--space-sm);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.btn-regen:hover {
|
||
background: var(--color-accent);
|
||
color: var(--color-bg);
|
||
}
|
||
|
||
.btn-revoke {
|
||
background: transparent;
|
||
color: var(--color-error, #e74c3c);
|
||
border: 1px solid var(--color-error, #e74c3c);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--space-xs) var(--space-sm);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.btn-revoke:hover {
|
||
background: var(--color-error, #e74c3c);
|
||
color: white;
|
||
}
|
||
</style>
|