From 5da223c5f82d6283564ee09535fa55cf83d70dbe Mon Sep 17 00:00:00 2001 From: xpltd Date: Thu, 19 Mar 2026 05:12:03 -0500 Subject: [PATCH] Admin UX, change password, mobile responsive, loading messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin: - Username field autofocused on login page - Change password section in Settings tab — current password verification, new password + confirm, min 4 chars, updates bcrypt hash at runtime via PUT /admin/password - Password change updates stored credentials in admin store Loading messages: - Replaced 'Peeking at the URL' with: Scanning the airwaves, Negotiating with the server, Cracking the codec, Reading the fine print, Locking on target Mobile responsive: - Progress column hidden on mobile (<640px) — table fits viewport - Action buttons compact (28px) on mobile with 2px gap - Status badges smaller on mobile (0.625rem) - Filter tabs scroll horizontally, Download All + Clear go full-width below as equal-sized buttons - min-width:0 on section containers prevents flex overflow - download-table-wrap constrained with max-width:100% --- backend/app/routers/admin.py | 56 ++++++++++ frontend/src/components/AdminLogin.vue | 1 + frontend/src/components/AdminPanel.vue | 130 +++++++++++++++++++++- frontend/src/components/AppLayout.vue | 1 + frontend/src/components/DownloadQueue.vue | 13 ++- frontend/src/components/DownloadTable.vue | 34 ++++-- frontend/src/components/UrlInput.vue | 10 +- 7 files changed, 231 insertions(+), 14 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 7413e68..80a47be 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -208,3 +208,59 @@ async def update_settings( logger.info("Admin updated default_audio_format to: %s", fmt) return {"updated": updated, "status": "ok"} + + +@router.put("/password") +async def change_password( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Change admin password (in-memory only — resets on restart). + + Accepts JSON body: + - current_password: str (required, must match current password) + - new_password: str (required, min 4 chars) + """ + import bcrypt + + body = await request.json() + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + + if not current or not new_pw: + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=422, + content={"detail": "current_password and new_password are required"}, + ) + + if len(new_pw) < 4: + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=422, + content={"detail": "New password must be at least 4 characters"}, + ) + + # Verify current password + config = request.app.state.config + try: + valid = bcrypt.checkpw( + current.encode("utf-8"), + config.admin.password_hash.encode("utf-8"), + ) + except (ValueError, TypeError): + valid = False + + if not valid: + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=403, + content={"detail": "Current password is incorrect"}, + ) + + # Hash and store new 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) + + return {"status": "ok", "message": "Password changed successfully"} diff --git a/frontend/src/components/AdminLogin.vue b/frontend/src/components/AdminLogin.vue index cdb1c94..a553027 100644 --- a/frontend/src/components/AdminLogin.vue +++ b/frontend/src/components/AdminLogin.vue @@ -20,6 +20,7 @@ async function handleLogin() { type="text" placeholder="Username" autocomplete="username" + autofocus /> -import { onMounted, ref } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useAdminStore } from '@/stores/admin' import { useConfigStore } from '@/stores/config' @@ -22,6 +22,20 @@ const defaultVideoFormat = ref('auto') const defaultAudioFormat = ref('auto') const settingsSaved = ref(false) +// Change password state +const currentPassword = ref('') +const newPassword = ref('') +const confirmPassword = ref('') +const changingPassword = ref(false) +const passwordChanged = ref(false) +const passwordError = ref(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` @@ -61,6 +75,44 @@ async function saveSettings() { } } +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) { + // Update stored credentials to use new password + store.password = newPassword.value + currentPassword.value = '' + newPassword.value = '' + confirmPassword.value = '' + passwordChanged.value = true + setTimeout(() => { passwordChanged.value = false }, 3000) + } else { + const data = await res.json() + passwordError.value = data.detail || 'Failed to change password' + } + } catch { + passwordError.value = 'Network error' + } finally { + changingPassword.value = false + } +} + async function toggleSession(sessionId: string) { if (expandedSessions.value.has(sessionId)) { expandedSessions.value.delete(sessionId) @@ -256,6 +308,47 @@ function formatFilesize(bytes: number | null): string {

Changes are applied immediately but reset on server restart.

+ +
+ +
+ +

Update the admin password. Takes effect immediately but resets on server restart.

+
+ + + +
+
+ + ✓ Password changed + {{ passwordError }} +
+
@@ -487,6 +580,41 @@ h3 { border-color: var(--color-accent); } +.settings-divider { + border: none; + border-top: 1px solid var(--color-border); + margin: var(--space-lg) 0; +} + +.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; +} + /* Expandable session rows */ .session-row.clickable { cursor: pointer; diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index 6a5bd60..d8b0b69 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -71,6 +71,7 @@ const activeTab = ref('submit') .section-submit, .section-queue { width: 100%; + min-width: 0; } /* Mobile navigation */ diff --git a/frontend/src/components/DownloadQueue.vue b/frontend/src/components/DownloadQueue.vue index e429fd0..0459665 100644 --- a/frontend/src/components/DownloadQueue.vue +++ b/frontend/src/components/DownloadQueue.vue @@ -235,16 +235,27 @@ function handleClear(): void { .queue-toolbar { flex-direction: column; align-items: stretch; + gap: var(--space-sm); } .queue-filters { + display: flex; + gap: var(--space-xs); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; } .queue-actions { - justify-content: flex-end; + display: flex; + gap: var(--space-xs); + } + + .queue-actions .btn-download-all, + .queue-actions .btn-clear { + flex: 1; + min-height: var(--touch-min); + justify-content: center; } .filter-btn { diff --git a/frontend/src/components/DownloadTable.vue b/frontend/src/components/DownloadTable.vue index 0244717..0c541d5 100644 --- a/frontend/src/components/DownloadTable.vue +++ b/frontend/src/components/DownloadTable.vue @@ -291,6 +291,7 @@ async function clearJob(jobId: string): Promise { .download-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; + max-width: 100%; } .download-table { @@ -514,29 +515,48 @@ async function clearJob(jobId: string): Promise { opacity: 0; } -/* Mobile: hide speed and ETA columns */ +/* Mobile: hide speed, ETA, and progress columns */ @media (max-width: 639px) { .hide-mobile { display: none; } .col-name { - min-width: 120px; - max-width: 200px; + min-width: 100px; + max-width: none; + } + + .col-status { + width: 75px; } .col-progress { - min-width: 80px; - width: 100px; + display: none; } .col-actions { - width: 80px; + width: auto; } .download-table th, .download-table td { - padding: var(--space-xs) var(--space-sm); + padding: var(--space-xs); + } + + .action-btn { + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + } + + .action-group { + gap: 2px; + } + + .status-badge { + font-size: 0.625rem; + padding: 1px 4px; } } diff --git a/frontend/src/components/UrlInput.vue b/frontend/src/components/UrlInput.vue index f83a679..e2336de 100644 --- a/frontend/src/components/UrlInput.vue +++ b/frontend/src/components/UrlInput.vue @@ -24,11 +24,11 @@ const isAnalyzing = ref(false) const analyzePhase = ref('') const analyzeError = ref(null) const phaseMessages = [ - 'Peeking at the URL…', - 'Interrogating the server…', - 'Decoding the matrix…', - 'Sniffing out formats…', - 'Almost there…', + 'Scanning the airwaves…', + 'Negotiating with the server…', + 'Cracking the codec…', + 'Reading the fine print…', + 'Locking on target…', ] let phaseTimer: ReturnType | null = null