diff --git a/frontend/src/account/useAccountDashboard.js b/frontend/src/account/useAccountDashboard.js new file mode 100644 index 0000000..013b223 --- /dev/null +++ b/frontend/src/account/useAccountDashboard.js @@ -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, + }; +} diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 0617aab..08afc78 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -418,465 +418,34 @@