mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
87f7996d5d
commit
5da223c5f8
7 changed files with 231 additions and 14 deletions
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue