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
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from flask import jsonify, g
|
from flask import jsonify, g
|
||||||
|
|
||||||
from .. import settings
|
from .. import settings
|
||||||
from ..keycloak import admin_client, require_auth, require_account_access
|
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:
|
def register(app) -> None:
|
||||||
@ -78,27 +79,19 @@ def register(app) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to update mail password"}), 502
|
return jsonify({"error": "failed to update mail password"}), 502
|
||||||
|
|
||||||
best_effort_post(settings.MAILU_SYNC_URL)
|
sync_ok = False
|
||||||
return jsonify({"password": password})
|
sync_error = ""
|
||||||
|
if settings.MAILU_SYNC_URL:
|
||||||
@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:
|
try:
|
||||||
admin_client().set_user_attribute(username, "jellyfin_app_password", password)
|
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:
|
except Exception:
|
||||||
return jsonify({"error": "failed to update jellyfin password"}), 502
|
sync_error = "sync request failed"
|
||||||
|
|
||||||
best_effort_post(settings.JELLYFIN_SYNC_URL)
|
return jsonify({"password": password, "sync_ok": sync_ok, "sync_error": sync_error})
|
||||||
return jsonify({"password": password})
|
|
||||||
|
|||||||
@ -50,7 +50,10 @@
|
|||||||
<div class="secret-head">
|
<div class="secret-head">
|
||||||
<div class="pill mono">Current password</div>
|
<div class="pill mono">Current password</div>
|
||||||
<div class="secret-actions">
|
<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">
|
<button class="copy mono" type="button" @click="mailu.revealPassword = !mailu.revealPassword">
|
||||||
{{ mailu.revealPassword ? "hide" : "show" }}
|
{{ mailu.revealPassword ? "hide" : "show" }}
|
||||||
</button>
|
</button>
|
||||||
@ -65,7 +68,10 @@
|
|||||||
<div v-if="mailu.newPassword" class="secret-box">
|
<div v-if="mailu.newPassword" class="secret-box">
|
||||||
<div class="secret-head">
|
<div class="secret-head">
|
||||||
<div class="pill mono pill-warn">Show once</div>
|
<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>
|
||||||
<div class="mono secret">{{ mailu.newPassword }}</div>
|
<div class="mono secret">{{ mailu.newPassword }}</div>
|
||||||
<div class="hint mono">Update your mail client password to match.</div>
|
<div class="hint mono">Update your mail client password to match.</div>
|
||||||
@ -82,7 +88,8 @@
|
|||||||
<span class="pill mono">{{ jellyfin.status }}</span>
|
<span class="pill mono">{{ jellyfin.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<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>
|
</p>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -94,19 +101,6 @@
|
|||||||
<span class="v mono">{{ jellyfin.username }}</span>
|
<span class="v mono">{{ jellyfin.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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 v-if="jellyfin.error" class="error-box">
|
||||||
<div class="mono">{{ jellyfin.error }}</div>
|
<div class="mono">{{ jellyfin.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -161,7 +155,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive } from "vue";
|
import { onMounted, reactive, watch } from "vue";
|
||||||
import { auth, authFetch, login } from "@/auth";
|
import { auth, authFetch, login } from "@/auth";
|
||||||
|
|
||||||
const mailu = reactive({
|
const mailu = reactive({
|
||||||
@ -179,8 +173,6 @@ const mailu = reactive({
|
|||||||
const jellyfin = reactive({
|
const jellyfin = reactive({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
username: "",
|
username: "",
|
||||||
rotating: false,
|
|
||||||
newPassword: "",
|
|
||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -194,18 +186,38 @@ const admin = reactive({
|
|||||||
|
|
||||||
const doLogin = () => login("/account");
|
const doLogin = () => login("/account");
|
||||||
|
|
||||||
onMounted(async () => {
|
const copied = reactive({});
|
||||||
if (!auth.authenticated) {
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (auth.ready && auth.authenticated) {
|
||||||
|
refreshOverview();
|
||||||
|
refreshAdminRequests();
|
||||||
|
} else {
|
||||||
mailu.status = "login required";
|
mailu.status = "login required";
|
||||||
jellyfin.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() {
|
async function refreshOverview() {
|
||||||
|
mailu.error = "";
|
||||||
|
jellyfin.error = "";
|
||||||
try {
|
try {
|
||||||
const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" } });
|
const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" } });
|
||||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||||
@ -265,21 +277,18 @@ async function rotateMailu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateJellyfin() {
|
function fallbackCopy(text) {
|
||||||
jellyfin.error = "";
|
const textarea = document.createElement("textarea");
|
||||||
jellyfin.newPassword = "";
|
textarea.value = text;
|
||||||
jellyfin.rotating = true;
|
textarea.setAttribute("readonly", "");
|
||||||
try {
|
textarea.style.position = "fixed";
|
||||||
const resp = await authFetch("/api/account/jellyfin/rotate", { method: "POST" });
|
textarea.style.top = "-9999px";
|
||||||
const data = await resp.json().catch(() => ({}));
|
textarea.style.left = "-9999px";
|
||||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
document.body.appendChild(textarea);
|
||||||
jellyfin.newPassword = data.password || "";
|
textarea.select();
|
||||||
jellyfin.status = "updated";
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
} catch (err) {
|
document.execCommand("copy");
|
||||||
jellyfin.error = err.message || "Rotation failed";
|
document.body.removeChild(textarea);
|
||||||
} finally {
|
|
||||||
jellyfin.rotating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approve(username) {
|
async function approve(username) {
|
||||||
@ -316,13 +325,29 @@ async function deny(username) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy(text) {
|
async function copy(key, text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text);
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -473,6 +498,14 @@ button.primary {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copied {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(120, 255, 160, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box {
|
.error-box {
|
||||||
|
|||||||
@ -170,7 +170,21 @@ async function submit() {
|
|||||||
async function copyRequestCode() {
|
async function copyRequestCode() {
|
||||||
if (!requestCode.value) return;
|
if (!requestCode.value) return;
|
||||||
try {
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(requestCode.value);
|
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;
|
copied.value = true;
|
||||||
setTimeout(() => (copied.value = false), 1500);
|
setTimeout(() => (copied.value = false), 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user