Admin UX, change password, mobile responsive, loading messages

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%
This commit is contained in:
xpltd 2026-03-19 05:12:03 -05:00
parent 87f7996d5d
commit 5da223c5f8
7 changed files with 231 additions and 14 deletions

View file

@ -208,3 +208,59 @@ async def update_settings(
logger.info("Admin updated default_audio_format to: %s", fmt) logger.info("Admin updated default_audio_format to: %s", fmt)
return {"updated": updated, "status": "ok"} 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"}

View file

@ -20,6 +20,7 @@ async function handleLogin() {
type="text" type="text"
placeholder="Username" placeholder="Username"
autocomplete="username" autocomplete="username"
autofocus
/> />
<input <input
v-model="pass" v-model="pass"

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
@ -22,6 +22,20 @@ const defaultVideoFormat = ref('auto')
const defaultAudioFormat = ref('auto') const defaultAudioFormat = ref('auto')
const settingsSaved = ref(false) 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<string | null>(null)
const canChangePassword = computed(() =>
currentPassword.value.length > 0 &&
newPassword.value.length >= 4 &&
newPassword.value === confirmPassword.value
)
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 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) { async function toggleSession(sessionId: string) {
if (expandedSessions.value.has(sessionId)) { if (expandedSessions.value.has(sessionId)) {
expandedSessions.value.delete(sessionId) expandedSessions.value.delete(sessionId)
@ -256,6 +308,47 @@ function formatFilesize(bytes: number | null): string {
<p class="field-hint" style="margin-top: var(--space-md);"> <p class="field-hint" style="margin-top: var(--space-md);">
Changes are applied immediately but reset on server restart. Changes are applied immediately but reset on server restart.
</p> </p>
<hr class="settings-divider" />
<div class="settings-field">
<label>Change Password</label>
<p class="field-hint">Update the admin password. Takes effect immediately but resets on server restart.</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"
/>
</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>
</div> </div>
</template> </template>
</div> </div>
@ -487,6 +580,41 @@ h3 {
border-color: var(--color-accent); 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 */ /* Expandable session rows */
.session-row.clickable { .session-row.clickable {
cursor: pointer; cursor: pointer;

View file

@ -71,6 +71,7 @@ const activeTab = ref<MobileTab>('submit')
.section-submit, .section-submit,
.section-queue { .section-queue {
width: 100%; width: 100%;
min-width: 0;
} }
/* Mobile navigation */ /* Mobile navigation */

View file

@ -235,16 +235,27 @@ function handleClear(): void {
.queue-toolbar { .queue-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: var(--space-sm);
} }
.queue-filters { .queue-filters {
display: flex;
gap: var(--space-xs);
overflow-x: auto; overflow-x: auto;
flex-wrap: nowrap; flex-wrap: nowrap;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.queue-actions { .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 { .filter-btn {

View file

@ -291,6 +291,7 @@ async function clearJob(jobId: string): Promise<void> {
.download-table-wrap { .download-table-wrap {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
max-width: 100%;
} }
.download-table { .download-table {
@ -514,29 +515,48 @@ async function clearJob(jobId: string): Promise<void> {
opacity: 0; opacity: 0;
} }
/* Mobile: hide speed and ETA columns */ /* Mobile: hide speed, ETA, and progress columns */
@media (max-width: 639px) { @media (max-width: 639px) {
.hide-mobile { .hide-mobile {
display: none; display: none;
} }
.col-name { .col-name {
min-width: 120px; min-width: 100px;
max-width: 200px; max-width: none;
}
.col-status {
width: 75px;
} }
.col-progress { .col-progress {
min-width: 80px; display: none;
width: 100px;
} }
.col-actions { .col-actions {
width: 80px; width: auto;
} }
.download-table th, .download-table th,
.download-table td { .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;
} }
} }

View file

@ -24,11 +24,11 @@ const isAnalyzing = ref(false)
const analyzePhase = ref<string>('') const analyzePhase = ref<string>('')
const analyzeError = ref<string | null>(null) const analyzeError = ref<string | null>(null)
const phaseMessages = [ const phaseMessages = [
'Peeking at the URL…', 'Scanning the airwaves…',
'Interrogating the server…', 'Negotiating with the server…',
'Decoding the matrix…', 'Cracking the codec…',
'Sniffing out formats…', 'Reading the fine print…',
'Almost there…', 'Locking on target…',
] ]
let phaseTimer: ReturnType<typeof setInterval> | null = null let phaseTimer: ReturnType<typeof setInterval> | null = null