portal: sync mailu rotate and fix account UI
This commit is contained in:
parent
a5ab2ad896
commit
7dac934a81
@ -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})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user