portal: sync mailu rotate and fix account UI

This commit is contained in:
Brad Stein 2026-01-02 02:53:49 -03:00
parent a5ab2ad896
commit 7dac934a81
3 changed files with 109 additions and 69 deletions

View File

@ -3,11 +3,12 @@ from __future__ import annotations
import time
from typing import Any
import httpx
from flask import jsonify, g
from .. import settings
from ..keycloak import admin_client, require_auth, require_account_access
from ..utils import best_effort_post, random_password
from ..utils import random_password
def register(app) -> None:
@ -78,27 +79,19 @@ def register(app) -> None:
except Exception:
return jsonify({"error": "failed to update mail password"}), 502
best_effort_post(settings.MAILU_SYNC_URL)
return jsonify({"password": password})
sync_ok = False
sync_error = ""
if settings.MAILU_SYNC_URL:
try:
with httpx.Client(timeout=30) as client:
resp = client.post(
settings.MAILU_SYNC_URL,
json={"ts": int(time.time()), "wait": True, "reason": "portal_mailu_rotate"},
)
sync_ok = resp.status_code == 200
if not sync_ok:
sync_error = f"sync status {resp.status_code}"
except Exception:
sync_error = "sync request failed"
@app.route("/api/account/jellyfin/rotate", methods=["POST"])
@require_auth
def account_jellyfin_rotate() -> Any:
ok, resp = require_account_access()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
password = random_password()
try:
admin_client().set_user_attribute(username, "jellyfin_app_password", password)
except Exception:
return jsonify({"error": "failed to update jellyfin password"}), 502
best_effort_post(settings.JELLYFIN_SYNC_URL)
return jsonify({"password": password})
return jsonify({"password": password, "sync_ok": sync_ok, "sync_error": sync_error})

View File

@ -50,7 +50,10 @@
<div class="secret-head">
<div class="pill mono">Current password</div>
<div class="secret-actions">
<button class="copy mono" type="button" @click="copy(mailu.currentPassword)">copy</button>
<button class="copy mono" type="button" @click="copy('mailu-current', mailu.currentPassword)">
copy
<span v-if="copied['mailu-current']" class="copied">copied</span>
</button>
<button class="copy mono" type="button" @click="mailu.revealPassword = !mailu.revealPassword">
{{ mailu.revealPassword ? "hide" : "show" }}
</button>
@ -65,7 +68,10 @@
<div v-if="mailu.newPassword" class="secret-box">
<div class="secret-head">
<div class="pill mono pill-warn">Show once</div>
<button class="copy mono" type="button" @click="copy(mailu.newPassword)">copy</button>
<button class="copy mono" type="button" @click="copy('mailu-new', mailu.newPassword)">
copy
<span v-if="copied['mailu-new']" class="copied">copied</span>
</button>
</div>
<div class="mono secret">{{ mailu.newPassword }}</div>
<div class="hint mono">Update your mail client password to match.</div>
@ -82,7 +88,8 @@
<span class="pill mono">{{ jellyfin.status }}</span>
</div>
<p class="muted">
Jellyfin authentication is backed by LDAP. If your login ever fails, rotate an app password and re-sync.
Jellyfin authentication is backed by LDAP (Keycloak is the source of truth). Use your Keycloak username and
password.
</p>
<div class="kv">
<div class="row">
@ -94,19 +101,6 @@
<span class="v mono">{{ jellyfin.username }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="jellyfin.rotating" @click="rotateJellyfin">
{{ jellyfin.rotating ? "Rotating..." : "Rotate Jellyfin app password" }}
</button>
</div>
<div v-if="jellyfin.newPassword" class="secret-box">
<div class="secret-head">
<div class="pill mono pill-warn">Show once</div>
<button class="copy mono" type="button" @click="copy(jellyfin.newPassword)">copy</button>
</div>
<div class="mono secret">{{ jellyfin.newPassword }}</div>
<div class="hint mono">Use your Keycloak username and this password on Jellyfin.</div>
</div>
<div v-if="jellyfin.error" class="error-box">
<div class="mono">{{ jellyfin.error }}</div>
</div>
@ -161,7 +155,7 @@
</template>
<script setup>
import { onMounted, reactive } from "vue";
import { onMounted, reactive, watch } from "vue";
import { auth, authFetch, login } from "@/auth";
const mailu = reactive({
@ -179,8 +173,6 @@ const mailu = reactive({
const jellyfin = reactive({
status: "loading",
username: "",
rotating: false,
newPassword: "",
error: "",
});
@ -194,18 +186,38 @@ const admin = reactive({
const doLogin = () => login("/account");
onMounted(async () => {
if (!auth.authenticated) {
const copied = reactive({});
onMounted(() => {
if (auth.ready && auth.authenticated) {
refreshOverview();
refreshAdminRequests();
} else {
mailu.status = "login required";
jellyfin.status = "login required";
return;
}
await refreshOverview();
await refreshAdminRequests();
});
watch(
() => [auth.ready, auth.authenticated],
([ready, authenticated]) => {
if (!ready) return;
if (!authenticated) {
mailu.status = "login required";
jellyfin.status = "login required";
admin.enabled = false;
admin.requests = [];
return;
}
refreshOverview();
refreshAdminRequests();
},
{ immediate: false },
);
async function refreshOverview() {
mailu.error = "";
jellyfin.error = "";
try {
const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" } });
if (!resp.ok) throw new Error(`status ${resp.status}`);
@ -265,21 +277,18 @@ async function rotateMailu() {
}
}
async function rotateJellyfin() {
jellyfin.error = "";
jellyfin.newPassword = "";
jellyfin.rotating = true;
try {
const resp = await authFetch("/api/account/jellyfin/rotate", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
jellyfin.newPassword = data.password || "";
jellyfin.status = "updated";
} catch (err) {
jellyfin.error = err.message || "Rotation failed";
} finally {
jellyfin.rotating = false;
}
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
async function approve(username) {
@ -316,12 +325,28 @@ async function deny(username) {
}
}
async function copy(text) {
async function copy(key, text) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// ignore
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
copied[key] = true;
window.setTimeout(() => {
copied[key] = false;
}, 1500);
} catch (err) {
try {
fallbackCopy(text);
copied[key] = true;
window.setTimeout(() => {
copied[key] = false;
}, 1500);
} catch {
// ignore
}
}
}
</script>
@ -473,6 +498,14 @@ button.primary {
border-radius: 10px;
padding: 6px 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.copied {
font-size: 12px;
color: rgba(120, 255, 160, 0.9);
}
.error-box {

View File

@ -170,7 +170,21 @@ async function submit() {
async function copyRequestCode() {
if (!requestCode.value) return;
try {
await navigator.clipboard.writeText(requestCode.value);
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(requestCode.value);
} else {
const textarea = document.createElement("textarea");
textarea.value = requestCode.value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch (err) {