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 @@
+
+ Wolf
+
+ {{ wolf.loading ? "loading..." : wolf.status }}
+
+