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)
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"
placeholder="Username"
autocomplete="username"
autofocus
/>
<input
v-model="pass"

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
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<string | 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`
@ -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 {
<p class="field-hint" style="margin-top: var(--space-md);">
Changes are applied immediately but reset on server restart.
</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>
</template>
</div>
@ -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;

View file

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

View file

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

View file

@ -291,6 +291,7 @@ async function clearJob(jobId: string): Promise<void> {
.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<void> {
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;
}
}

View file

@ -24,11 +24,11 @@ const isAnalyzing = ref(false)
const analyzePhase = ref<string>('')
const analyzeError = ref<string | null>(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<typeof setInterval> | null = null