refactor(bstein-home): extract account dashboard flow
This commit is contained in:
parent
4ad9803c0c
commit
0273da9e79
497
frontend/src/account/useAccountDashboard.js
Normal file
497
frontend/src/account/useAccountDashboard.js
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { auth, authFetch, login } from "@/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Account page dashboard and admin action state.
|
||||||
|
*
|
||||||
|
* WHY: this page coordinates several downstream services plus admin
|
||||||
|
* approval flows, so isolating the orchestration keeps the SFC readable and
|
||||||
|
* gives tests a direct seam for service state behavior.
|
||||||
|
*
|
||||||
|
* @returns {object} reactive service cards, admin state, and event handlers.
|
||||||
|
*/
|
||||||
|
export function useAccountDashboard() {
|
||||||
|
const mailu = reactive({
|
||||||
|
status: "loading",
|
||||||
|
imap: "mail.bstein.dev:993 (TLS)",
|
||||||
|
smtp: "mail.bstein.dev:587 (STARTTLS)",
|
||||||
|
username: "",
|
||||||
|
currentPassword: "",
|
||||||
|
revealPassword: false,
|
||||||
|
rotating: false,
|
||||||
|
newPassword: "",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyfin = reactive({
|
||||||
|
status: "loading",
|
||||||
|
username: "",
|
||||||
|
syncStatus: "",
|
||||||
|
syncDetail: "",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const vaultwarden = reactive({
|
||||||
|
status: "loading",
|
||||||
|
username: "",
|
||||||
|
syncedAt: "",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextcloudMail = reactive({
|
||||||
|
status: "loading",
|
||||||
|
primaryEmail: "",
|
||||||
|
accountCount: "",
|
||||||
|
syncedAt: "",
|
||||||
|
syncing: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const wger = reactive({
|
||||||
|
status: "loading",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
passwordUpdatedAt: "",
|
||||||
|
revealPassword: false,
|
||||||
|
resetting: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firefly = reactive({
|
||||||
|
status: "loading",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
passwordUpdatedAt: "",
|
||||||
|
revealPassword: false,
|
||||||
|
resetting: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = reactive({
|
||||||
|
enabled: false,
|
||||||
|
loading: false,
|
||||||
|
requests: [],
|
||||||
|
error: "",
|
||||||
|
acting: {},
|
||||||
|
flags: [],
|
||||||
|
flagsLoading: false,
|
||||||
|
notes: {},
|
||||||
|
selectedFlags: {},
|
||||||
|
});
|
||||||
|
const onboardingUrl = ref("/onboarding");
|
||||||
|
const vaultwardenReady = computed(() =>
|
||||||
|
["ready", "already_present", "active", "grandfathered"].includes(vaultwarden.status),
|
||||||
|
);
|
||||||
|
const vaultwardenDisplayStatus = computed(() => (vaultwardenReady.value ? "ready" : vaultwarden.status));
|
||||||
|
const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
|
||||||
|
|
||||||
|
const doLogin = () => login("/account");
|
||||||
|
|
||||||
|
const copied = reactive({});
|
||||||
|
const normalizeEmail = (value) => (typeof value === "string" ? value.toLowerCase() : "");
|
||||||
|
onMounted(() => {
|
||||||
|
if (auth.ready && auth.authenticated) {
|
||||||
|
refreshOverview();
|
||||||
|
refreshAdminRequests();
|
||||||
|
refreshAdminFlags();
|
||||||
|
} else {
|
||||||
|
mailu.status = "login required";
|
||||||
|
nextcloudMail.status = "login required";
|
||||||
|
jellyfin.status = "login required";
|
||||||
|
vaultwarden.status = "login required";
|
||||||
|
wger.status = "login required";
|
||||||
|
firefly.status = "login required";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [auth.ready, auth.authenticated],
|
||||||
|
([ready, authenticated]) => {
|
||||||
|
if (!ready) return;
|
||||||
|
if (!authenticated) {
|
||||||
|
mailu.status = "login required";
|
||||||
|
nextcloudMail.status = "login required";
|
||||||
|
jellyfin.status = "login required";
|
||||||
|
vaultwarden.status = "login required";
|
||||||
|
wger.status = "login required";
|
||||||
|
firefly.status = "login required";
|
||||||
|
onboardingUrl.value = "/onboarding";
|
||||||
|
admin.enabled = false;
|
||||||
|
admin.requests = [];
|
||||||
|
admin.flags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshOverview();
|
||||||
|
refreshAdminRequests();
|
||||||
|
refreshAdminFlags();
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function refreshOverview() {
|
||||||
|
mailu.error = "";
|
||||||
|
jellyfin.error = "";
|
||||||
|
vaultwarden.error = "";
|
||||||
|
nextcloudMail.error = "";
|
||||||
|
wger.error = "";
|
||||||
|
firefly.error = "";
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/account/overview", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || `status ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
mailu.status = data.mailu?.status || "ready";
|
||||||
|
mailu.username = normalizeEmail(data.mailu?.username) || normalizeEmail(auth.email) || auth.username;
|
||||||
|
mailu.currentPassword = data.mailu?.app_password || "";
|
||||||
|
nextcloudMail.status = data.nextcloud_mail?.status || "unknown";
|
||||||
|
nextcloudMail.primaryEmail = normalizeEmail(data.nextcloud_mail?.primary_email) || "";
|
||||||
|
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
|
||||||
|
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
|
||||||
|
wger.status = data.wger?.status || "unknown";
|
||||||
|
wger.username = normalizeEmail(data.wger?.username) || mailu.username || auth.username;
|
||||||
|
wger.password = data.wger?.password || "";
|
||||||
|
wger.passwordUpdatedAt = data.wger?.password_updated_at || "";
|
||||||
|
firefly.status = data.firefly?.status || "unknown";
|
||||||
|
firefly.username = normalizeEmail(data.firefly?.username) || mailu.username || auth.username;
|
||||||
|
firefly.password = data.firefly?.password || "";
|
||||||
|
firefly.passwordUpdatedAt = data.firefly?.password_updated_at || "";
|
||||||
|
vaultwarden.status = data.vaultwarden?.status || "unknown";
|
||||||
|
vaultwarden.username = normalizeEmail(data.vaultwarden?.username) || mailu.username || auth.username;
|
||||||
|
vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
|
||||||
|
jellyfin.status = data.jellyfin?.status || "ready";
|
||||||
|
jellyfin.username = data.jellyfin?.username || auth.username;
|
||||||
|
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
||||||
|
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
||||||
|
onboardingUrl.value = data.onboarding_url || "/onboarding";
|
||||||
|
} catch (err) {
|
||||||
|
mailu.status = "unavailable";
|
||||||
|
nextcloudMail.status = "unavailable";
|
||||||
|
wger.status = "unavailable";
|
||||||
|
firefly.status = "unavailable";
|
||||||
|
vaultwarden.status = "unavailable";
|
||||||
|
jellyfin.status = "unavailable";
|
||||||
|
jellyfin.syncStatus = "";
|
||||||
|
jellyfin.syncDetail = "";
|
||||||
|
onboardingUrl.value = "/onboarding";
|
||||||
|
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
||||||
|
mailu.error = message;
|
||||||
|
nextcloudMail.error = message;
|
||||||
|
wger.error = message;
|
||||||
|
firefly.error = message;
|
||||||
|
vaultwarden.error = message;
|
||||||
|
jellyfin.error = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAdminRequests() {
|
||||||
|
if (!auth.authenticated) {
|
||||||
|
admin.enabled = false;
|
||||||
|
admin.requests = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admin.error = "";
|
||||||
|
admin.loading = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/admin/access/requests", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (resp.status === 403) {
|
||||||
|
admin.enabled = false;
|
||||||
|
admin.requests = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
admin.enabled = true;
|
||||||
|
admin.requests = Array.isArray(data.requests) ? data.requests : [];
|
||||||
|
for (const req of admin.requests) {
|
||||||
|
if (!req?.username) continue;
|
||||||
|
if (!(req.username in admin.notes)) admin.notes[req.username] = "";
|
||||||
|
if (!(req.username in admin.selectedFlags)) admin.selectedFlags[req.username] = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
admin.enabled = false;
|
||||||
|
admin.requests = [];
|
||||||
|
admin.error = err.message || "Failed to load access requests.";
|
||||||
|
} finally {
|
||||||
|
admin.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAdminFlags() {
|
||||||
|
if (!auth.authenticated) {
|
||||||
|
admin.flags = [];
|
||||||
|
admin.flagsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admin.flagsLoading = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/admin/access/flags", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (resp.status === 403) {
|
||||||
|
admin.flags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
admin.flags = Array.isArray(data.flags) ? data.flags : [];
|
||||||
|
} catch (err) {
|
||||||
|
admin.flags = [];
|
||||||
|
admin.error = admin.error || err.message || "Failed to load access flags.";
|
||||||
|
} finally {
|
||||||
|
admin.flagsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(username, flag) {
|
||||||
|
const selected = admin.selectedFlags[username];
|
||||||
|
return Array.isArray(selected) && selected.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatName(req) {
|
||||||
|
if (!req) return "unknown";
|
||||||
|
const parts = [];
|
||||||
|
if (req.first_name && String(req.first_name).trim()) {
|
||||||
|
parts.push(String(req.first_name).trim());
|
||||||
|
}
|
||||||
|
if (req.last_name && String(req.last_name).trim()) {
|
||||||
|
parts.push(String(req.last_name).trim());
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(" ") : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionError(err, fallback) {
|
||||||
|
const message = err?.message || "";
|
||||||
|
if (!message) return fallback;
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
if (normalized.includes("ariadne unavailable") || normalized.includes("status 502") || normalized.includes("status 503")) {
|
||||||
|
return "Ariadne is busy. Please try again in a moment.";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlag(username, flag, event) {
|
||||||
|
const checked = Boolean(event?.target?.checked);
|
||||||
|
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];
|
||||||
|
const next = checked ? Array.from(new Set([...selected, flag])) : selected.filter((item) => item !== flag);
|
||||||
|
admin.selectedFlags[username] = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateMailu() {
|
||||||
|
mailu.error = "";
|
||||||
|
mailu.newPassword = "";
|
||||||
|
mailu.rotating = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/account/mailu/rotate", { method: "POST" });
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
mailu.newPassword = data.password || "";
|
||||||
|
if (mailu.newPassword) {
|
||||||
|
mailu.currentPassword = mailu.newPassword;
|
||||||
|
mailu.revealPassword = true;
|
||||||
|
}
|
||||||
|
const syncEnabled = Boolean(data.sync_enabled);
|
||||||
|
const syncOk = Boolean(data.sync_ok);
|
||||||
|
const syncError = data.sync_error || "";
|
||||||
|
if (!syncEnabled) {
|
||||||
|
mailu.status = "updated";
|
||||||
|
mailu.error = "Mail sync is not configured; password may not take effect until an admin sync runs.";
|
||||||
|
} else if (!syncOk) {
|
||||||
|
mailu.status = "sync pending";
|
||||||
|
mailu.error = syncError || "Mail sync did not confirm success yet. Try again in a moment.";
|
||||||
|
} else {
|
||||||
|
mailu.status = "updated";
|
||||||
|
}
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (err) {
|
||||||
|
mailu.error = formatActionError(err, "Rotation failed");
|
||||||
|
} finally {
|
||||||
|
mailu.rotating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetWger() {
|
||||||
|
wger.error = "";
|
||||||
|
wger.resetting = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/account/wger/reset", { method: "POST" });
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
if (data.password) {
|
||||||
|
wger.password = data.password;
|
||||||
|
wger.revealPassword = true;
|
||||||
|
}
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (err) {
|
||||||
|
wger.error = formatActionError(err, "Reset failed");
|
||||||
|
} finally {
|
||||||
|
wger.resetting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFirefly() {
|
||||||
|
firefly.error = "";
|
||||||
|
firefly.resetting = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/account/firefly/reset", { method: "POST" });
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
if (data.password) {
|
||||||
|
firefly.password = data.password;
|
||||||
|
firefly.revealPassword = true;
|
||||||
|
}
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (err) {
|
||||||
|
firefly.error = formatActionError(err, "Reset failed");
|
||||||
|
} finally {
|
||||||
|
firefly.resetting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncNextcloudMail() {
|
||||||
|
nextcloudMail.error = "";
|
||||||
|
nextcloudMail.syncing = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/account/nextcloud/mail/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ wait: true }),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (err) {
|
||||||
|
const message = formatActionError(err, "Sync failed");
|
||||||
|
if (message.toLowerCase().includes("ariadne is busy")) {
|
||||||
|
nextcloudMail.error = "Ariadne is busy. Refresh in a moment; the sync may have completed.";
|
||||||
|
} else {
|
||||||
|
nextcloudMail.error = message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
nextcloudMail.syncing = 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) {
|
||||||
|
admin.error = "";
|
||||||
|
admin.acting[username] = true;
|
||||||
|
try {
|
||||||
|
const flags = Array.isArray(admin.selectedFlags[username]) ? admin.selectedFlags[username] : [];
|
||||||
|
const note = (admin.notes[username] || "").trim();
|
||||||
|
const payload = {};
|
||||||
|
if (flags.length) payload.flags = flags;
|
||||||
|
if (note) payload.note = note;
|
||||||
|
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
}
|
||||||
|
await refreshAdminRequests();
|
||||||
|
} catch (err) {
|
||||||
|
admin.error = err.message || "Approve failed";
|
||||||
|
} finally {
|
||||||
|
admin.acting[username] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deny(username) {
|
||||||
|
admin.error = "";
|
||||||
|
admin.acting[username] = true;
|
||||||
|
try {
|
||||||
|
const note = (admin.notes[username] || "").trim();
|
||||||
|
const payload = note ? { note } : {};
|
||||||
|
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `status ${resp.status}`);
|
||||||
|
}
|
||||||
|
await refreshAdminRequests();
|
||||||
|
} catch (err) {
|
||||||
|
admin.error = err.message || "Deny failed";
|
||||||
|
} finally {
|
||||||
|
admin.acting[username] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(key, text) {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
copied[key] = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copied[key] = false;
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
fallbackCopy(text);
|
||||||
|
copied[key] = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copied[key] = false;
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth,
|
||||||
|
mailu,
|
||||||
|
jellyfin,
|
||||||
|
vaultwarden,
|
||||||
|
nextcloudMail,
|
||||||
|
wger,
|
||||||
|
firefly,
|
||||||
|
admin,
|
||||||
|
onboardingUrl,
|
||||||
|
vaultwardenReady,
|
||||||
|
vaultwardenDisplayStatus,
|
||||||
|
vaultwardenOrder,
|
||||||
|
doLogin,
|
||||||
|
copied,
|
||||||
|
hasFlag,
|
||||||
|
formatName,
|
||||||
|
toggleFlag,
|
||||||
|
rotateMailu,
|
||||||
|
resetWger,
|
||||||
|
resetFirefly,
|
||||||
|
syncNextcloudMail,
|
||||||
|
approve,
|
||||||
|
deny,
|
||||||
|
copy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -418,465 +418,34 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { useAccountDashboard } from "../account/useAccountDashboard";
|
||||||
import { auth, authFetch, login } from "@/auth";
|
|
||||||
|
|
||||||
const mailu = reactive({
|
const {
|
||||||
status: "loading",
|
auth,
|
||||||
imap: "mail.bstein.dev:993 (TLS)",
|
mailu,
|
||||||
smtp: "mail.bstein.dev:587 (STARTTLS)",
|
jellyfin,
|
||||||
username: "",
|
vaultwarden,
|
||||||
currentPassword: "",
|
nextcloudMail,
|
||||||
revealPassword: false,
|
wger,
|
||||||
rotating: false,
|
firefly,
|
||||||
newPassword: "",
|
admin,
|
||||||
error: "",
|
onboardingUrl,
|
||||||
});
|
vaultwardenReady,
|
||||||
|
vaultwardenDisplayStatus,
|
||||||
const jellyfin = reactive({
|
vaultwardenOrder,
|
||||||
status: "loading",
|
doLogin,
|
||||||
username: "",
|
copied,
|
||||||
syncStatus: "",
|
hasFlag,
|
||||||
syncDetail: "",
|
formatName,
|
||||||
error: "",
|
toggleFlag,
|
||||||
});
|
rotateMailu,
|
||||||
|
resetWger,
|
||||||
const vaultwarden = reactive({
|
resetFirefly,
|
||||||
status: "loading",
|
syncNextcloudMail,
|
||||||
username: "",
|
approve,
|
||||||
syncedAt: "",
|
deny,
|
||||||
error: "",
|
copy,
|
||||||
});
|
} = useAccountDashboard();
|
||||||
|
|
||||||
const nextcloudMail = reactive({
|
|
||||||
status: "loading",
|
|
||||||
primaryEmail: "",
|
|
||||||
accountCount: "",
|
|
||||||
syncedAt: "",
|
|
||||||
syncing: false,
|
|
||||||
error: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const wger = reactive({
|
|
||||||
status: "loading",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
passwordUpdatedAt: "",
|
|
||||||
revealPassword: false,
|
|
||||||
resetting: false,
|
|
||||||
error: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const firefly = reactive({
|
|
||||||
status: "loading",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
passwordUpdatedAt: "",
|
|
||||||
revealPassword: false,
|
|
||||||
resetting: false,
|
|
||||||
error: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const admin = reactive({
|
|
||||||
enabled: false,
|
|
||||||
loading: false,
|
|
||||||
requests: [],
|
|
||||||
error: "",
|
|
||||||
acting: {},
|
|
||||||
flags: [],
|
|
||||||
flagsLoading: false,
|
|
||||||
notes: {},
|
|
||||||
selectedFlags: {},
|
|
||||||
});
|
|
||||||
const onboardingUrl = ref("/onboarding");
|
|
||||||
const vaultwardenReady = computed(() =>
|
|
||||||
["ready", "already_present", "active", "grandfathered"].includes(vaultwarden.status),
|
|
||||||
);
|
|
||||||
const vaultwardenDisplayStatus = computed(() => (vaultwardenReady.value ? "ready" : vaultwarden.status));
|
|
||||||
const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
|
|
||||||
|
|
||||||
const doLogin = () => login("/account");
|
|
||||||
|
|
||||||
const copied = reactive({});
|
|
||||||
const normalizeEmail = (value) => (typeof value === "string" ? value.toLowerCase() : "");
|
|
||||||
onMounted(() => {
|
|
||||||
if (auth.ready && auth.authenticated) {
|
|
||||||
refreshOverview();
|
|
||||||
refreshAdminRequests();
|
|
||||||
refreshAdminFlags();
|
|
||||||
} else {
|
|
||||||
mailu.status = "login required";
|
|
||||||
nextcloudMail.status = "login required";
|
|
||||||
jellyfin.status = "login required";
|
|
||||||
vaultwarden.status = "login required";
|
|
||||||
wger.status = "login required";
|
|
||||||
firefly.status = "login required";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [auth.ready, auth.authenticated],
|
|
||||||
([ready, authenticated]) => {
|
|
||||||
if (!ready) return;
|
|
||||||
if (!authenticated) {
|
|
||||||
mailu.status = "login required";
|
|
||||||
nextcloudMail.status = "login required";
|
|
||||||
jellyfin.status = "login required";
|
|
||||||
vaultwarden.status = "login required";
|
|
||||||
wger.status = "login required";
|
|
||||||
firefly.status = "login required";
|
|
||||||
onboardingUrl.value = "/onboarding";
|
|
||||||
admin.enabled = false;
|
|
||||||
admin.requests = [];
|
|
||||||
admin.flags = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refreshOverview();
|
|
||||||
refreshAdminRequests();
|
|
||||||
refreshAdminFlags();
|
|
||||||
},
|
|
||||||
{ immediate: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
async function refreshOverview() {
|
|
||||||
mailu.error = "";
|
|
||||||
jellyfin.error = "";
|
|
||||||
vaultwarden.error = "";
|
|
||||||
nextcloudMail.error = "";
|
|
||||||
wger.error = "";
|
|
||||||
firefly.error = "";
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/account/overview", {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(data?.error || `status ${resp.status}`);
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
mailu.status = data.mailu?.status || "ready";
|
|
||||||
mailu.username = normalizeEmail(data.mailu?.username) || normalizeEmail(auth.email) || auth.username;
|
|
||||||
mailu.currentPassword = data.mailu?.app_password || "";
|
|
||||||
nextcloudMail.status = data.nextcloud_mail?.status || "unknown";
|
|
||||||
nextcloudMail.primaryEmail = normalizeEmail(data.nextcloud_mail?.primary_email) || "";
|
|
||||||
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
|
|
||||||
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
|
|
||||||
wger.status = data.wger?.status || "unknown";
|
|
||||||
wger.username = normalizeEmail(data.wger?.username) || mailu.username || auth.username;
|
|
||||||
wger.password = data.wger?.password || "";
|
|
||||||
wger.passwordUpdatedAt = data.wger?.password_updated_at || "";
|
|
||||||
firefly.status = data.firefly?.status || "unknown";
|
|
||||||
firefly.username = normalizeEmail(data.firefly?.username) || mailu.username || auth.username;
|
|
||||||
firefly.password = data.firefly?.password || "";
|
|
||||||
firefly.passwordUpdatedAt = data.firefly?.password_updated_at || "";
|
|
||||||
vaultwarden.status = data.vaultwarden?.status || "unknown";
|
|
||||||
vaultwarden.username = normalizeEmail(data.vaultwarden?.username) || mailu.username || auth.username;
|
|
||||||
vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
|
|
||||||
jellyfin.status = data.jellyfin?.status || "ready";
|
|
||||||
jellyfin.username = data.jellyfin?.username || auth.username;
|
|
||||||
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
|
||||||
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
|
||||||
onboardingUrl.value = data.onboarding_url || "/onboarding";
|
|
||||||
} catch (err) {
|
|
||||||
mailu.status = "unavailable";
|
|
||||||
nextcloudMail.status = "unavailable";
|
|
||||||
wger.status = "unavailable";
|
|
||||||
firefly.status = "unavailable";
|
|
||||||
vaultwarden.status = "unavailable";
|
|
||||||
jellyfin.status = "unavailable";
|
|
||||||
jellyfin.syncStatus = "";
|
|
||||||
jellyfin.syncDetail = "";
|
|
||||||
onboardingUrl.value = "/onboarding";
|
|
||||||
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
|
||||||
mailu.error = message;
|
|
||||||
nextcloudMail.error = message;
|
|
||||||
wger.error = message;
|
|
||||||
firefly.error = message;
|
|
||||||
vaultwarden.error = message;
|
|
||||||
jellyfin.error = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAdminRequests() {
|
|
||||||
if (!auth.authenticated) {
|
|
||||||
admin.enabled = false;
|
|
||||||
admin.requests = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
admin.error = "";
|
|
||||||
admin.loading = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/admin/access/requests", {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (resp.status === 403) {
|
|
||||||
admin.enabled = false;
|
|
||||||
admin.requests = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
admin.enabled = true;
|
|
||||||
admin.requests = Array.isArray(data.requests) ? data.requests : [];
|
|
||||||
for (const req of admin.requests) {
|
|
||||||
if (!req?.username) continue;
|
|
||||||
if (!(req.username in admin.notes)) admin.notes[req.username] = "";
|
|
||||||
if (!(req.username in admin.selectedFlags)) admin.selectedFlags[req.username] = [];
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
admin.enabled = false;
|
|
||||||
admin.requests = [];
|
|
||||||
admin.error = err.message || "Failed to load access requests.";
|
|
||||||
} finally {
|
|
||||||
admin.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAdminFlags() {
|
|
||||||
if (!auth.authenticated) {
|
|
||||||
admin.flags = [];
|
|
||||||
admin.flagsLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
admin.flagsLoading = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/admin/access/flags", {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (resp.status === 403) {
|
|
||||||
admin.flags = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
admin.flags = Array.isArray(data.flags) ? data.flags : [];
|
|
||||||
} catch (err) {
|
|
||||||
admin.flags = [];
|
|
||||||
admin.error = admin.error || err.message || "Failed to load access flags.";
|
|
||||||
} finally {
|
|
||||||
admin.flagsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFlag(username, flag) {
|
|
||||||
const selected = admin.selectedFlags[username];
|
|
||||||
return Array.isArray(selected) && selected.includes(flag);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatName(req) {
|
|
||||||
if (!req) return "unknown";
|
|
||||||
const parts = [];
|
|
||||||
if (req.first_name && String(req.first_name).trim()) {
|
|
||||||
parts.push(String(req.first_name).trim());
|
|
||||||
}
|
|
||||||
if (req.last_name && String(req.last_name).trim()) {
|
|
||||||
parts.push(String(req.last_name).trim());
|
|
||||||
}
|
|
||||||
return parts.length ? parts.join(" ") : "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatActionError(err, fallback) {
|
|
||||||
const message = err?.message || "";
|
|
||||||
if (!message) return fallback;
|
|
||||||
const normalized = message.toLowerCase();
|
|
||||||
if (normalized.includes("ariadne unavailable") || normalized.includes("status 502") || normalized.includes("status 503")) {
|
|
||||||
return "Ariadne is busy. Please try again in a moment.";
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFlag(username, flag, event) {
|
|
||||||
const checked = Boolean(event?.target?.checked);
|
|
||||||
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];
|
|
||||||
const next = checked ? Array.from(new Set([...selected, flag])) : selected.filter((item) => item !== flag);
|
|
||||||
admin.selectedFlags[username] = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateMailu() {
|
|
||||||
mailu.error = "";
|
|
||||||
mailu.newPassword = "";
|
|
||||||
mailu.rotating = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/account/mailu/rotate", { method: "POST" });
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
mailu.newPassword = data.password || "";
|
|
||||||
if (mailu.newPassword) {
|
|
||||||
mailu.currentPassword = mailu.newPassword;
|
|
||||||
mailu.revealPassword = true;
|
|
||||||
}
|
|
||||||
const syncEnabled = Boolean(data.sync_enabled);
|
|
||||||
const syncOk = Boolean(data.sync_ok);
|
|
||||||
const syncError = data.sync_error || "";
|
|
||||||
if (!syncEnabled) {
|
|
||||||
mailu.status = "updated";
|
|
||||||
mailu.error = "Mail sync is not configured; password may not take effect until an admin sync runs.";
|
|
||||||
} else if (!syncOk) {
|
|
||||||
mailu.status = "sync pending";
|
|
||||||
mailu.error = syncError || "Mail sync did not confirm success yet. Try again in a moment.";
|
|
||||||
} else {
|
|
||||||
mailu.status = "updated";
|
|
||||||
}
|
|
||||||
await refreshOverview();
|
|
||||||
} catch (err) {
|
|
||||||
mailu.error = formatActionError(err, "Rotation failed");
|
|
||||||
} finally {
|
|
||||||
mailu.rotating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetWger() {
|
|
||||||
wger.error = "";
|
|
||||||
wger.resetting = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/account/wger/reset", { method: "POST" });
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
if (data.password) {
|
|
||||||
wger.password = data.password;
|
|
||||||
wger.revealPassword = true;
|
|
||||||
}
|
|
||||||
await refreshOverview();
|
|
||||||
} catch (err) {
|
|
||||||
wger.error = formatActionError(err, "Reset failed");
|
|
||||||
} finally {
|
|
||||||
wger.resetting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetFirefly() {
|
|
||||||
firefly.error = "";
|
|
||||||
firefly.resetting = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/account/firefly/reset", { method: "POST" });
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
if (data.password) {
|
|
||||||
firefly.password = data.password;
|
|
||||||
firefly.revealPassword = true;
|
|
||||||
}
|
|
||||||
await refreshOverview();
|
|
||||||
} catch (err) {
|
|
||||||
firefly.error = formatActionError(err, "Reset failed");
|
|
||||||
} finally {
|
|
||||||
firefly.resetting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncNextcloudMail() {
|
|
||||||
nextcloudMail.error = "";
|
|
||||||
nextcloudMail.syncing = true;
|
|
||||||
try {
|
|
||||||
const resp = await authFetch("/api/account/nextcloud/mail/sync", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ wait: true }),
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
await refreshOverview();
|
|
||||||
} catch (err) {
|
|
||||||
const message = formatActionError(err, "Sync failed");
|
|
||||||
if (message.toLowerCase().includes("ariadne is busy")) {
|
|
||||||
nextcloudMail.error = "Ariadne is busy. Refresh in a moment; the sync may have completed.";
|
|
||||||
} else {
|
|
||||||
nextcloudMail.error = message;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
nextcloudMail.syncing = 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) {
|
|
||||||
admin.error = "";
|
|
||||||
admin.acting[username] = true;
|
|
||||||
try {
|
|
||||||
const flags = Array.isArray(admin.selectedFlags[username]) ? admin.selectedFlags[username] : [];
|
|
||||||
const note = (admin.notes[username] || "").trim();
|
|
||||||
const payload = {};
|
|
||||||
if (flags.length) payload.flags = flags;
|
|
||||||
if (note) payload.note = note;
|
|
||||||
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
}
|
|
||||||
await refreshAdminRequests();
|
|
||||||
} catch (err) {
|
|
||||||
admin.error = err.message || "Approve failed";
|
|
||||||
} finally {
|
|
||||||
admin.acting[username] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deny(username) {
|
|
||||||
admin.error = "";
|
|
||||||
admin.acting[username] = true;
|
|
||||||
try {
|
|
||||||
const note = (admin.notes[username] || "").trim();
|
|
||||||
const payload = note ? { note } : {};
|
|
||||||
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(data.error || `status ${resp.status}`);
|
|
||||||
}
|
|
||||||
await refreshAdminRequests();
|
|
||||||
} catch (err) {
|
|
||||||
admin.error = err.message || "Deny failed";
|
|
||||||
} finally {
|
|
||||||
admin.acting[username] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copy(key, text) {
|
|
||||||
if (!text) return;
|
|
||||||
try {
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../styles/account.css"></style>
|
<style scoped src="../styles/account.css"></style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user