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)
|
||||
|
||||
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"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
v-model="pass"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const activeTab = ref<MobileTab>('submit')
|
|||
.section-submit,
|
||||
.section-queue {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile navigation */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue