feat(FN-5595): add oauth relogin banner with validity logger

This merge implements an OAuth relogin banner feature (FN-5595) that displays in the dashboard when OAuth tokens expire. The feature includes a new `OAuthReloginBanner` component with styling and tests, an OAuth validity logger in the engine for tracking token state, and corresponding API route inte

Fusion-Task-Id: FN-5595

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5595
This commit is contained in:
gsxdsm
2026-05-27 00:39:20 -07:00
parent 0d0d07ded4
commit da34bd06e3
13 changed files with 623 additions and 22 deletions

View File

@@ -3,7 +3,7 @@ import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
import { NtfyNotifier } from "../notifier.js";
import { NotificationService, OAuthExpiryMonitor } from "../notification/index.js";
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "../notification/index.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({
notificationServiceStop: vi.fn(),
oauthExpiryMonitorStart: vi.fn(async () => undefined),
oauthExpiryMonitorStop: vi.fn(),
oauthValidityLoggerStart: vi.fn(async () => undefined),
oauthValidityLoggerStop: vi.fn(),
runtimeConfigurePrMonitoring: vi.fn(),
prHandlerCreateFollowUpTask: vi.fn(async () => undefined),
}));
@@ -98,6 +100,10 @@ vi.mock("../notification/index.js", () => ({
start: mocks.oauthExpiryMonitorStart,
stop: mocks.oauthExpiryMonitorStop,
})),
OAuthValidityLogger: vi.fn().mockImplementation(() => ({
start: mocks.oauthValidityLoggerStart,
stop: mocks.oauthValidityLoggerStop,
})),
}));
vi.mock("../auth-storage.js", () => ({
@@ -261,6 +267,8 @@ beforeEach(() => {
mocks.notificationServiceStop.mockClear();
mocks.oauthExpiryMonitorStart.mockClear();
mocks.oauthExpiryMonitorStop.mockClear();
mocks.oauthValidityLoggerStart.mockClear();
mocks.oauthValidityLoggerStop.mockClear();
mocks.execFile.mockImplementation((
_file: string,

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { OAuthValidityLogger } from "../oauth-validity-logger.js";
import type { AuthStorageLike } from "../oauth-expiry-monitor.js";
function createAuthStorage(providers: Array<{ id: string; name: string }>, credentials: Record<string, any>): AuthStorageLike {
return {
reload: vi.fn(),
getOAuthProviders: () => providers,
get: (providerId: string) => credentials[providerId],
};
}
describe("OAuthValidityLogger", () => {
it("logs one line per expired oauth credential on start", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[
{ id: "openai-codex", name: "OpenAI Codex" },
{ id: "claude", name: "Claude" },
],
{
"openai-codex": { type: "oauth", expires: now - 1_000 },
claude: { type: "oauth", expires: now - 500 },
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).toHaveBeenCalledTimes(2);
validityLogger.stop();
vi.useRealTimers();
});
it("logs again on interval without dedupe", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
await vi.advanceTimersByTimeAsync(1_000);
expect(logger).toHaveBeenCalledTimes(2);
validityLogger.stop();
vi.useRealTimers();
});
it("does not log for valid oauth, api key, or missing expires", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[
{ id: "valid-oauth", name: "Valid OAuth" },
{ id: "api-key-provider", name: "API Key" },
{ id: "missing-expiry", name: "Missing Expiry" },
],
{
"valid-oauth": { type: "oauth", expires: now + 10_000 },
"api-key-provider": { type: "api_key" },
"missing-expiry": { type: "oauth" },
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).not.toHaveBeenCalled();
validityLogger.stop();
vi.useRealTimers();
});
it("stop cancels the interval", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
validityLogger.stop();
await vi.advanceTimersByTimeAsync(5_000);
expect(logger).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("continues iterating when one provider throws", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage: AuthStorageLike = {
reload: vi.fn(),
getOAuthProviders: () => [
{ id: "broken", name: "Broken" },
{ id: "claude", name: "Claude" },
],
get: (providerId: string) => {
if (providerId === "broken") {
throw new Error("boom");
}
return { type: "oauth", expires: now - 100 };
},
};
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).toHaveBeenCalledTimes(1);
expect(logger).toHaveBeenCalledWith(
"oauth credential expired — provider re-login required",
expect.objectContaining({ providerId: "claude" }),
);
validityLogger.stop();
vi.useRealTimers();
});
it("never includes token material in log metadata", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{
"openai-codex": {
type: "oauth",
expires: now - 1_000,
accessToken: "secret-access",
refreshToken: "secret-refresh",
},
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
const [, meta] = logger.mock.calls[0] ?? [];
expect(Object.keys(meta ?? {}).sort()).toEqual(["expiresAt", "providerId", "providerName"]);
validityLogger.stop();
vi.useRealTimers();
});
});

View File

@@ -9,3 +9,5 @@ export type { NotificationServiceOptions } from "./notification-service.js";
export { OAuthExpiryMonitor } from "./oauth-expiry-monitor.js";
export type { AuthStorageLike as OAuthExpiryAuthStorageLike, OAuthExpiryMonitorOptions } from "./oauth-expiry-monitor.js";
export { OAuthValidityLogger } from "./oauth-validity-logger.js";

View File

@@ -0,0 +1,71 @@
import { schedulerLog } from "../logger.js";
import type { AuthStorageLike } from "./oauth-expiry-monitor.js";
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000;
interface OAuthValidityLoggerOptions {
authStorage: AuthStorageLike;
intervalMs?: number;
clock?: () => number;
logger?: (msg: string, meta?: Record<string, unknown>) => void;
}
export class OAuthValidityLogger {
private readonly intervalMs: number;
private readonly clock: () => number;
private readonly logger: (msg: string, meta?: Record<string, unknown>) => void;
private timer: NodeJS.Timeout | null = null;
constructor(private readonly opts: OAuthValidityLoggerOptions) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.clock = opts.clock ?? Date.now;
this.logger = opts.logger ?? ((message, meta) => schedulerLog.warn(message, meta));
}
async start(): Promise<void> {
if (this.timer) {
return;
}
await this.check();
this.timer = setInterval(() => {
void this.check();
}, this.intervalMs);
this.timer.unref?.();
}
stop(): void {
if (!this.timer) {
return;
}
clearInterval(this.timer);
this.timer = null;
}
async check(): Promise<void> {
this.opts.authStorage.reload?.();
const providers = this.opts.authStorage.getOAuthProviders?.() ?? [];
const now = this.clock();
for (const provider of providers) {
try {
const credential = this.opts.authStorage.get?.(provider.id);
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
continue;
}
if (credential.expires > now) {
continue;
}
this.logger("oauth credential expired — provider re-login required", {
providerId: provider.id,
providerName: provider.name,
expiresAt: new Date(credential.expires).toISOString(),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.warn(`OAuth validity logger failed for provider=${provider.id}: ${message}`);
}
}
}
}

View File

@@ -19,7 +19,7 @@ import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { PrMonitor } from "./pr-monitor.js";
import { PrCommentHandler } from "./pr-comment-handler.js";
import { NtfyNotifier } from "./notifier.js";
import { NotificationService, OAuthExpiryMonitor } from "./notification/index.js";
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js";
import type { NotificationChatStore } from "./notification/notification-service.js";
import { GridlockDetector } from "./gridlock-detector.js";
import { createFusionAuthStorage } from "./auth-storage.js";
@@ -177,6 +177,7 @@ export class ProjectEngine {
private notifier?: NtfyNotifier;
private notificationService?: NotificationService;
private oauthExpiryMonitor?: OAuthExpiryMonitor;
private oauthValidityLogger?: OAuthValidityLogger;
private gridlockDetector?: GridlockDetector;
private cronRunner?: CronRunner;
private automationStore?: AutomationStoreType;
@@ -353,11 +354,14 @@ export class ProjectEngine {
agentNameResolver,
});
await this.notificationService.start();
const authStorage = createFusionAuthStorage();
this.oauthExpiryMonitor = new OAuthExpiryMonitor({
authStorage: createFusionAuthStorage(),
authStorage,
notificationService: this.notificationService,
});
await this.oauthExpiryMonitor.start();
this.oauthValidityLogger = new OAuthValidityLogger({ authStorage });
await this.oauthValidityLogger.start();
// Backward-compatibility shim for gridlock notifications.
this.notifier = new NtfyNotifier(
@@ -581,6 +585,7 @@ export class ProjectEngine {
// Stop auxiliary subsystems
this.oauthExpiryMonitor?.stop();
this.oauthValidityLogger?.stop();
this.notificationService?.stop();
this.notifier?.stop();
this.gridlockDetector?.stop();