diff --git a/frontend/src/account/accountErrors.js b/frontend/src/account/accountErrors.js new file mode 100644 index 0000000..c23961f --- /dev/null +++ b/frontend/src/account/accountErrors.js @@ -0,0 +1,9 @@ +export 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; +} diff --git a/frontend/src/account/useAccountDashboard.js b/frontend/src/account/useAccountDashboard.js index 723292d..aa366cd 100644 --- a/frontend/src/account/useAccountDashboard.js +++ b/frontend/src/account/useAccountDashboard.js @@ -1,12 +1,10 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { auth, authFetch, login } from "@/auth"; +import { formatActionError } from "./accountErrors"; +import { useWolfDashboard } from "./useWolfDashboard"; /** * 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() { @@ -66,32 +64,7 @@ export function useAccountDashboard() { error: "", }); - const wolf = reactive({ - status: "loading", - error: "", - loading: false, - unlocking: false, - pairing: {}, - actioning: "", - manualUnlocking: false, - canControlGpu: false, - moonlightHost: "moonlight.bstein.dev", - sourceIp: "", - unlockTtlSeconds: 28800, - gpuPriority: "unknown", - gameModeStatus: "unknown", - gameModeActive: false, - selectedGame: "steam", - note: "", - manualIp: "", - manualUser: "", - clients: [], - pendingPairRequests: [], - activeUnlocks: [], - sessions: [], - apps: [], - pinInputs: {}, - }); + const { wolf, refreshWolf, unlockWolf, pairWolf, setWolfGameMode, adminUnlockWolfIp } = useWolfDashboard(); const admin = reactive({ enabled: false, @@ -105,9 +78,7 @@ export function useAccountDashboard() { selectedFlags: {}, }); const onboardingUrl = ref("/onboarding"); - const vaultwardenReady = computed(() => - ["ready", "already_present", "active", "grandfathered"].includes(vaultwarden.status), - ); + 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)); @@ -218,46 +189,6 @@ export function useAccountDashboard() { } } - async function refreshWolf() { - if (!auth.authenticated) { - wolf.status = "login required"; - return; - } - wolf.error = ""; - wolf.loading = true; - try { - const resp = await authFetch("/api/account/wolf", { - headers: { Accept: "application/json" }, - cache: "no-store", - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); - - wolf.canControlGpu = Boolean(data.can_control_gpu); - wolf.moonlightHost = data.moonlight?.host || "moonlight.bstein.dev"; - wolf.sourceIp = data.moonlight?.source_ip || ""; - wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800); - wolf.gpuPriority = data.gpu?.priority || "unknown"; - wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown"; - wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active); - wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : []; - wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : []; - wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : []; - wolf.apps = Array.isArray(data.wolf?.apps) ? data.wolf.apps : []; - wolf.activeUnlocks = Array.isArray(data.firewall?.active_unlocks) ? data.firewall.active_unlocks : []; - for (const req of wolf.pendingPairRequests) { - const key = req?.pair_secret || req?.name; - if (key && !(key in wolf.pinInputs)) wolf.pinInputs[key] = ""; - } - wolf.status = data.wolf?.api_enabled ? "ready" : "degraded"; - } catch (err) { - wolf.status = "unavailable"; - wolf.error = formatActionError(err, "Wolf status unavailable"); - } finally { - wolf.loading = false; - } - } - async function refreshAdminRequests() { if (!auth.authenticated) { admin.enabled = false; @@ -339,16 +270,6 @@ export function useAccountDashboard() { 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]] : []; @@ -453,90 +374,6 @@ export function useAccountDashboard() { } } - async function unlockWolf() { - wolf.error = ""; - wolf.unlocking = true; - try { - const resp = await authFetch("/api/account/wolf/firewall/unlock", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); - await refreshWolf(); - } catch (err) { - wolf.error = formatActionError(err, "Unlock failed"); - } finally { - wolf.unlocking = false; - } - } - - async function pairWolf(pairSecret) { - if (!pairSecret) return; - wolf.error = ""; - wolf.pairing[pairSecret] = true; - try { - const pin = wolf.pinInputs[pairSecret] || ""; - const resp = await authFetch("/api/account/wolf/pairing/submit-pin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pair_secret: pairSecret, pin }), - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); - wolf.pinInputs[pairSecret] = ""; - await refreshWolf(); - } catch (err) { - wolf.error = formatActionError(err, "Pairing failed"); - } finally { - wolf.pairing[pairSecret] = false; - } - } - - async function setWolfGameMode(action) { - wolf.error = ""; - wolf.actioning = action; - try { - const resp = await authFetch(`/api/account/wolf/game-mode/${action}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ game: wolf.selectedGame, note: wolf.note }), - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); - await refreshWolf(); - } catch (err) { - wolf.error = formatActionError(err, "GPU priority update failed"); - } finally { - wolf.actioning = ""; - } - } - - async function adminUnlockWolfIp() { - if (!wolf.manualIp) return; - wolf.error = ""; - wolf.manualUnlocking = true; - try { - const payload = { ip: wolf.manualIp }; - if (wolf.manualUser) payload.target_user = wolf.manualUser; - const resp = await authFetch("/api/account/wolf/admin/firewall/unlock", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); - wolf.manualIp = ""; - wolf.manualUser = ""; - await refreshWolf(); - } catch (err) { - wolf.error = formatActionError(err, "Manual unlock failed"); - } finally { - wolf.manualUnlocking = false; - } - } - // WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed. function fallbackCopy(text) { const textarea = document.createElement("textarea"); diff --git a/frontend/src/account/useWolfDashboard.js b/frontend/src/account/useWolfDashboard.js new file mode 100644 index 0000000..17414f2 --- /dev/null +++ b/frontend/src/account/useWolfDashboard.js @@ -0,0 +1,163 @@ +import { reactive } from "vue"; +import { auth, authFetch } from "@/auth"; +import { formatActionError } from "./accountErrors"; + +/** + * Build the Wolf card state and actions for the Account page. + * + * @returns {object} reactive Wolf state plus refresh, unlock, pairing, and admin GPU actions. + */ +export function useWolfDashboard() { + const wolf = reactive({ + status: "loading", + error: "", + loading: false, + unlocking: false, + pairing: {}, + actioning: "", + manualUnlocking: false, + canControlGpu: false, + moonlightHost: "moonlight.bstein.dev", + sourceIp: "", + unlockTtlSeconds: 28800, + gpuPriority: "unknown", + gameModeStatus: "unknown", + gameModeActive: false, + selectedGame: "steam", + note: "", + manualIp: "", + manualUser: "", + clients: [], + pendingPairRequests: [], + activeUnlocks: [], + sessions: [], + apps: [], + pinInputs: {}, + }); + + async function refreshWolf() { + if (!auth.authenticated) { + wolf.status = "login required"; + return; + } + wolf.error = ""; + wolf.loading = true; + try { + const resp = await authFetch("/api/account/wolf", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + + wolf.canControlGpu = Boolean(data.can_control_gpu); + wolf.moonlightHost = data.moonlight?.host || "moonlight.bstein.dev"; + wolf.sourceIp = data.moonlight?.source_ip || ""; + wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800); + wolf.gpuPriority = data.gpu?.priority || "unknown"; + wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown"; + wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active); + wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : []; + wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : []; + wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : []; + wolf.apps = Array.isArray(data.wolf?.apps) ? data.wolf.apps : []; + wolf.activeUnlocks = Array.isArray(data.firewall?.active_unlocks) ? data.firewall.active_unlocks : []; + for (const req of wolf.pendingPairRequests) { + const key = req?.pair_secret || req?.name; + if (key && !(key in wolf.pinInputs)) wolf.pinInputs[key] = ""; + } + wolf.status = data.wolf?.api_enabled ? "ready" : "degraded"; + } catch (err) { + wolf.status = "unavailable"; + wolf.error = formatActionError(err, "Wolf status unavailable"); + } finally { + wolf.loading = false; + } + } + + async function unlockWolf() { + wolf.error = ""; + wolf.unlocking = true; + try { + const resp = await authFetch("/api/account/wolf/firewall/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Unlock failed"); + } finally { + wolf.unlocking = false; + } + } + + async function pairWolf(pairSecret) { + if (!pairSecret) return; + wolf.error = ""; + wolf.pairing[pairSecret] = true; + try { + const pin = wolf.pinInputs[pairSecret] || ""; + const resp = await authFetch("/api/account/wolf/pairing/submit-pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pair_secret: pairSecret, pin }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + wolf.pinInputs[pairSecret] = ""; + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Pairing failed"); + } finally { + wolf.pairing[pairSecret] = false; + } + } + + async function setWolfGameMode(action) { + wolf.error = ""; + wolf.actioning = action; + try { + const resp = await authFetch(`/api/account/wolf/game-mode/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ game: wolf.selectedGame, note: wolf.note }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "GPU priority update failed"); + } finally { + wolf.actioning = ""; + } + } + + async function adminUnlockWolfIp() { + if (!wolf.manualIp) return; + wolf.error = ""; + wolf.manualUnlocking = true; + try { + const payload = { ip: wolf.manualIp }; + if (wolf.manualUser) payload.target_user = wolf.manualUser; + const resp = await authFetch("/api/account/wolf/admin/firewall/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`); + wolf.manualIp = ""; + wolf.manualUser = ""; + await refreshWolf(); + } catch (err) { + wolf.error = formatActionError(err, "Manual unlock failed"); + } finally { + wolf.manualUnlocking = false; + } + } + + return { wolf, refreshWolf, unlockWolf, pairWolf, setWolfGameMode, adminUnlockWolfIp }; +} diff --git a/frontend/src/components/WolfAccountCard.vue b/frontend/src/components/WolfAccountCard.vue new file mode 100644 index 0000000..e40820f --- /dev/null +++ b/frontend/src/components/WolfAccountCard.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 443b64e..faf578f 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -192,120 +192,14 @@
-
-
-

Wolf

- - {{ wolf.loading ? "loading..." : wolf.status }} - -
-
-
- Moonlight - {{ wolf.moonlightHost }} -
-
- Your IP - {{ wolf.sourceIp || "unknown" }} -
-
- GPU priority - {{ wolf.gpuPriority }} -
-
- Firewall unlocks - {{ wolf.activeUnlocks.length }} -
-
- Paired devices - {{ wolf.clients.map((client) => client.name).join(", ") || "none" }} -
-
- -
- - -
- -
-
-
Pairing
-
-
-
{{ req.name || "pending device" }}
- - -
-
- -
-
-
Admin
-
-
- - - - -
-
- - - -
-
- -
- Active sessions: {{ wolf.sessions.length }} -
-
-
{{ wolf.error }}
-
-
+
@@ -533,6 +427,7 @@