Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions desktop/frontend/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { __setPlatformForTests } from "./platform";
import { createFakePlatform } from "./platform/__tests__/_fakePlatform";
import { createFakePlatform, fakeEventBus } from "./platform/__tests__/_fakePlatform";
import { __setBindingsForTest } from "./lib/api";
import { TYPE, NIL_SID, encodeFrame, encodeText } from "./lib/proto";
import type { SessionInfo } from "./lib/connection";
Expand Down Expand Up @@ -81,10 +81,12 @@ describe("web capability gates", () => {
expect(source).toContain('<TranslatePanelHost v-if="caps.pluginHost"');
});

test("settings close only refreshes desktop relay config when Wails bindings exist", () => {
test("relay config changes refresh both desktop connection state and identity", () => {
expect(source).toContain("function refreshDesktopRelayConfig()");
expect(source).toContain("function onRelayConfigChanged()");
expect(source).toContain("if (caps.wailsBindings)");
expect(source).toContain('@relay-config-changed="refreshDesktopRelayConfig"');
expect(source).toContain("void refreshRelayIdentity()");
expect(source).toContain('@relay-config-changed="onRelayConfigChanged"');
expect(source).toContain('@close="onSettingsClose"');
expect(source).not.toContain('@close="showSettings = false; settingsInitialTab = undefined; refreshRelayConfig()"');
});
Expand Down Expand Up @@ -1573,6 +1575,49 @@ describe("admin view (main-area swap)", () => {

expect(wrapper.find('[data-testid="admin-panel-stub"]').exists()).toBe(false);
});

it("refreshes admin identity when the desktop uplink authenticates after boot", async () => {
const platform = createFakePlatform();
platform.events = fakeEventBus();
platform.relay.fetchMe = vi.fn()
.mockRejectedValueOnce(new Error("relay unavailable during boot"))
.mockResolvedValueOnce({ user_id: "u1", email: "[email protected]", is_admin: true });
__setPlatformForTests(platform);

const wrapper = mountAdminApp();
await flushPromises();
expect(wrapper.find('[data-testid="toggle-admin"]').exists()).toBe(false);

platform.events.emit("relay:auth-info", { user_id: "u1" });
await flushPromises();

expect(platform.relay.fetchMe).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-testid="toggle-admin"]').exists()).toBe(true);
});

it("ignores an older identity request that completes after a new relay user", async () => {
const platform = createFakePlatform();
platform.events = fakeEventBus();
let resolveBootIdentity!: (value: { user_id: string; email: string; is_admin: boolean }) => void;
const bootIdentity = new Promise<{ user_id: string; email: string; is_admin: boolean }>((resolve) => {
resolveBootIdentity = resolve;
});
platform.relay.fetchMe = vi.fn()
.mockReturnValueOnce(bootIdentity)
.mockResolvedValueOnce({ user_id: "u2", email: "[email protected]", is_admin: false });
__setPlatformForTests(platform);

const wrapper = mountAdminApp();
await vi.waitFor(() => expect(platform.relay.fetchMe).toHaveBeenCalledTimes(1));

platform.events.emit("relay:auth-info", { user_id: "u2" });
await flushPromises();
resolveBootIdentity({ user_id: "u1", email: "[email protected]", is_admin: true });
await flushPromises();

expect(platform.relay.fetchMe).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-testid="toggle-admin"]').exists()).toBe(false);
});
});

describe("recovery snapshot gated on caps.wailsBindings (web)", () => {
Expand Down
78 changes: 63 additions & 15 deletions desktop/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,39 @@ const starting = ref(false);
const showSettings = ref(false);
const toast = ref<string>("");

// Current relay identity, fetched once at boot (see onMounted). Exposed via
// defineExpose so tests can drive the isAdmin-transition watcher below
// without a second live fetchMe() call.
// Current relay identity, refreshed at boot and whenever relay authentication
// changes. Exposed via defineExpose so tests can drive the isAdmin-transition
// watcher below without a second live fetchMe() call.
const me = ref<RelayMe | null>(null);
const isAdmin = computed(() => me.value?.is_admin === true);
let relayIdentityRequestSeq = 0;

function clearRelayIdentity() {
relayIdentityRequestSeq++;
me.value = null;
}

async function refreshRelayIdentity(expectedUserID = "", reportFailure = false) {
const requestSeq = ++relayIdentityRequestSeq;
try {
const next = await $platform.relay.fetchMe();
if (requestSeq !== relayIdentityRequestSeq) return;
// relay:auth-info identifies the connection that triggered this refresh.
// A mismatch means the relay config changed while /api/me was in flight.
if (expectedUserID && next.user_id !== expectedUserID) {
me.value = null;
logWarn("app", "discarded relay identity from a different authenticated user");
return;
}
me.value = next;
} catch (e) {
if (requestSeq !== relayIdentityRequestSeq) return;
me.value = null;
if (reportFailure) {
logWarn("app", "failed to refresh relay identity", { error: errText(e) });
}
}
}
// Whether the main area shows AdminPanel instead of the pane-grid tabs.
// Not persisted (see spec §4.10) — always starts closed.
const adminViewOpen = ref(false);
Expand All @@ -361,6 +389,9 @@ const showSshHosts = ref(false);
let quitListenerOff: (() => void) | null = null;
let notificationClickListenerOff: (() => void) | null = null;
let widgetActivateListenerOff: (() => void) | null = null;
let relayAuthInfoListenerOff: (() => void) | null = null;
let relayAuthRestoredListenerOff: (() => void) | null = null;
let relayAuthErrorListenerOff: (() => void) | null = null;

function handleBeforeClose() {
// Best-effort final persist so a clean quit always lands the latest state.
Expand Down Expand Up @@ -902,6 +933,11 @@ function refreshDesktopRelayConfig() {
}
}

function onRelayConfigChanged() {
refreshDesktopRelayConfig();
void refreshRelayIdentity();
}

function onSettingsClose() {
showSettings.value = false;
settingsInitialTab.value = undefined;
Expand Down Expand Up @@ -1571,9 +1607,19 @@ onMounted(async () => {
} catch {
/* keep default empty; .is-maximized stays off on darwin-bug-side */
}
$platform.events.on('relay:auth-error', (data) => {
relayAuthErrorListenerOff = $platform.events.on('relay:auth-error', (data) => {
const d = data as { reason: string };
authError.value = d?.reason ?? null;
clearRelayIdentity();
});
relayAuthInfoListenerOff = $platform.events.on('relay:auth-info', (data) => {
const d = data as { user_id?: unknown };
const userID = typeof d?.user_id === "string" ? d.user_id : "";
if (!userID) {
clearRelayIdentity();
return;
}
void refreshRelayIdentity(userID, true);
});
$platform.events.on('relay:viewers', (data) => {
const d = data as { session_id: string; count: number };
Expand Down Expand Up @@ -1602,7 +1648,8 @@ onMounted(async () => {
void refreshShortcutBindings();
void refreshProfiles();
});
$platform.events.on('relay:auth-restored', () => {
relayAuthRestoredListenerOff = $platform.events.on('relay:auth-restored', () => {
void refreshRelayIdentity("", true);
if (caps.wailsBindings) return;
void (async () => {
try {
Expand All @@ -1617,15 +1664,9 @@ onMounted(async () => {
})();
});
// Fire-and-forget: drives TabBar's admin button + AdminPanel gating only.
// Not on the critical boot path — an unconfigured/unauthenticated relay
// rejects immediately and just leaves isAdmin false.
void (async () => {
try {
me.value = await $platform.relay.fetchMe();
} catch {
me.value = null;
}
})();
// Not on the critical boot path; auth lifecycle events retry this request
// after a transient startup failure or relay configuration change.
void refreshRelayIdentity();
syncRoute();
window.addEventListener("hashchange", syncRoute);
if (caps.wailsBindings) {
Expand Down Expand Up @@ -1796,6 +1837,13 @@ onUnmounted(() => {
notificationClickListenerOff = null;
widgetActivateListenerOff?.();
widgetActivateListenerOff = null;
relayAuthInfoListenerOff?.();
relayAuthInfoListenerOff = null;
relayAuthRestoredListenerOff?.();
relayAuthRestoredListenerOff = null;
relayAuthErrorListenerOff?.();
relayAuthErrorListenerOff = null;
clearRelayIdentity();
window.removeEventListener("hashchange", syncRoute);
sessionListStreams.detachAll();
if (toastHandle !== null) window.clearTimeout(toastHandle);
Expand Down Expand Up @@ -1941,7 +1989,7 @@ defineExpose({ me });
@appearance-changed="onAppearanceChanged"
@bindings-changed="onBindingsChanged"
@profiles-changed="onProfilesChanged"
@relay-config-changed="refreshDesktopRelayConfig"
@relay-config-changed="onRelayConfigChanged"
@session-created="onMobileSessionCreated"
@close="onSettingsClose"
/>
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading
Loading