feat(FN-812): add onboarding and API key auth status support
- Add modelOnboardingComplete field and auth status endpoint to dashboard API - Support both OAuth and API-key-backed providers in /auth/status route - Remove mission-interview module and related unused code (dashboard, engine, CLI) - Remove sync-openrouter-models changeset (no longer needed) - Add comprehensive tests for onboarding persistence and auth status - Fix TypeScript type narrowing for oauth/api_key provider union type
This commit is contained in:
@@ -632,6 +632,11 @@ export interface GlobalSettings {
|
||||
* the OpenRouter API at startup so the model picker shows all available
|
||||
* OpenRouter models (not just the static built-in list). Default: true. */
|
||||
openrouterModelSync?: boolean;
|
||||
/** When true, indicates the user has completed the AI model onboarding flow
|
||||
* (connected at least one provider and selected a default model). When
|
||||
* false/undefined, the dashboard will auto-open the onboarding modal.
|
||||
* Also set to true when the user explicitly dismisses onboarding. */
|
||||
modelOnboardingComplete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -847,6 +852,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
||||
ntfyEvents: ["in-review", "merged", "failed"],
|
||||
ntfyDashboardHost: undefined,
|
||||
openrouterModelSync: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -925,6 +931,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"ntfyDashboardHost",
|
||||
"defaultProjectId",
|
||||
"openrouterModelSync",
|
||||
"modelOnboardingComplete",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
|
||||
@@ -408,6 +408,8 @@ export interface AuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
/** Whether this provider uses OAuth or API key authentication */
|
||||
type?: "oauth" | "api_key";
|
||||
}
|
||||
|
||||
/** Fetch authentication status for all OAuth providers */
|
||||
@@ -431,6 +433,22 @@ export function logoutProvider(provider: string): Promise<{ success: boolean }>
|
||||
});
|
||||
}
|
||||
|
||||
/** Save an API key for an API-key-backed provider. */
|
||||
export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>("/auth/api-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, apiKey }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an API key for an API-key-backed provider. */
|
||||
export function clearApiKey(provider: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>("/auth/api-key", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- GitHub Import API ---
|
||||
|
||||
/** GitHub issue returned by the fetch endpoint */
|
||||
|
||||
@@ -1689,6 +1689,13 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthSt
|
||||
return Promise.resolve();
|
||||
}),
|
||||
logout: vi.fn(),
|
||||
getApiKeyProviders: vi.fn().mockReturnValue([
|
||||
{ id: "openrouter", name: "OpenRouter" },
|
||||
{ id: "kimi-coding", name: "Kimi" },
|
||||
]),
|
||||
hasApiKey: vi.fn().mockReturnValue(false),
|
||||
setApiKey: vi.fn(),
|
||||
clearApiKey: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AuthStorageLike;
|
||||
}
|
||||
@@ -1716,7 +1723,9 @@ describe("GET /auth/status", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers).toEqual([
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
]);
|
||||
expect(authStorage.reload).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1730,6 +1739,19 @@ describe("GET /auth/status", () => {
|
||||
expect(res.body.providers[0].authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("returns authenticated true for API-key provider when hasApiKey is true", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const openrouter = res.body.providers.find((p: any) => p.id === "openrouter");
|
||||
expect(openrouter).toBeDefined();
|
||||
expect(openrouter.authenticated).toBe(true);
|
||||
expect(openrouter.type).toBe("api_key");
|
||||
});
|
||||
|
||||
it("returns 500 on error", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("storage error");
|
||||
@@ -1849,6 +1871,168 @@ describe("POST /auth/logout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/api-key", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("saves an API key for a valid provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-or-v1-test-key",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
|
||||
});
|
||||
|
||||
it("trims whitespace from API key", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
apiKey: " sk-or-v1-test-key ",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(authStorage.setApiKey).toHaveBeenCalledWith("openrouter", "sk-or-v1-test-key");
|
||||
});
|
||||
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
apiKey: "sk-test",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("provider is required");
|
||||
});
|
||||
|
||||
it("returns 400 when apiKey is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("apiKey is required");
|
||||
});
|
||||
|
||||
it("returns 400 when apiKey is empty", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
apiKey: " ",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("apiKey is required");
|
||||
});
|
||||
|
||||
it("returns 400 for unknown provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "unknown-provider",
|
||||
apiKey: "sk-test",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Unknown API key provider");
|
||||
});
|
||||
|
||||
it("returns 400 when storage does not support API keys", async () => {
|
||||
const storageWithoutApiKeys = createMockAuthStorage({
|
||||
setApiKey: undefined,
|
||||
getApiKeyProviders: undefined,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-test",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not supported");
|
||||
});
|
||||
|
||||
it("returns 500 on storage error", async () => {
|
||||
(authStorage.setApiKey as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("disk full");
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-test",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("disk full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /auth/api-key", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("clears an API key for a provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(authStorage.clearApiKey).toHaveBeenCalledWith("openrouter");
|
||||
});
|
||||
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("provider is required");
|
||||
});
|
||||
|
||||
it("returns 400 when storage does not support API keys", async () => {
|
||||
const storageWithoutApiKeys = createMockAuthStorage({
|
||||
clearApiKey: undefined,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage: storageWithoutApiKeys }));
|
||||
|
||||
const res = await REQUEST(app, "DELETE", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "openrouter",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not supported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pause/Unpause endpoints", () => {
|
||||
let store: TaskStore;
|
||||
function buildApp() {
|
||||
@@ -5847,6 +6031,34 @@ describe("PUT /settings/global", () => {
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Write failed");
|
||||
});
|
||||
|
||||
it("persists modelOnboardingComplete flag", async () => {
|
||||
const updated = { modelOnboardingComplete: true };
|
||||
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updated);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings/global",
|
||||
JSON.stringify(updated),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
|
||||
expect(res.body.modelOnboardingComplete).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /settings/global returns modelOnboardingComplete value", async () => {
|
||||
const mockGlobalStore = createMockGlobalSettingsStore();
|
||||
mockGlobalStore.getSettings.mockResolvedValue({ modelOnboardingComplete: true, themeMode: "dark" });
|
||||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
|
||||
|
||||
const res = await GET(buildApp(), "/api/settings/global");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.modelOnboardingComplete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /settings/scopes", () => {
|
||||
|
||||
@@ -55,6 +55,14 @@ export interface AuthStorageLike {
|
||||
},
|
||||
): Promise<void>;
|
||||
logout(provider: string): void;
|
||||
/** Get providers that accept API keys (non-OAuth). Returns provider id and name. */
|
||||
getApiKeyProviders?(): Array<{ id: string; name: string }>;
|
||||
/** Save an API key for a provider. Creates or overwrites the existing key. */
|
||||
setApiKey?(providerId: string, apiKey: string): void;
|
||||
/** Remove the stored API key for a provider. No-op if not set. */
|
||||
clearApiKey?(providerId: string): void;
|
||||
/** Check if a provider has an API key configured. */
|
||||
hasApiKey?(providerId: string): boolean;
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
@@ -7576,19 +7584,37 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Returns list of OAuth providers with their authentication status.
|
||||
* Response: { providers: [{ id: string, name: string, authenticated: boolean }] }
|
||||
* Returns list of all providers with their authentication status and type.
|
||||
* Includes both OAuth-backed and API-key-backed providers.
|
||||
* Response: { providers: [{ id, name, authenticated, type }] }
|
||||
*/
|
||||
router.get("/auth/status", (_req, res) => {
|
||||
try {
|
||||
const storage = getAuthStorage();
|
||||
storage.reload();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
const providers = oauthProviders.map((p) => ({
|
||||
const providers: { id: string; name: string; authenticated: boolean; type: "oauth" | "api_key" }[] = oauthProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasAuth(p.id),
|
||||
type: "oauth" as const,
|
||||
}));
|
||||
|
||||
// Include API-key-backed providers if supported
|
||||
if (storage.getApiKeyProviders) {
|
||||
const apiKeyProviders = storage.getApiKeyProviders();
|
||||
for (const p of apiKeyProviders) {
|
||||
// Skip if already listed as an OAuth provider (avoid duplicates)
|
||||
if (providers.some((existing) => existing.id === p.id)) continue;
|
||||
providers.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasApiKey ? storage.hasApiKey(p.id) : false,
|
||||
type: "api_key" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ providers });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -7703,4 +7729,75 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/api-key
|
||||
* Save an API key for an API-key-backed provider.
|
||||
* Body: { provider: string, apiKey: string }
|
||||
* Response: { success: true }
|
||||
*
|
||||
* Validates the provider exists, is API-key-backed, and the key is non-empty.
|
||||
* Never returns the key in any response.
|
||||
*/
|
||||
router.post("/auth/api-key", (req, res) => {
|
||||
try {
|
||||
const { provider, apiKey } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
res.status(400).json({ error: "provider is required" });
|
||||
return;
|
||||
}
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
res.status(400).json({ error: "apiKey is required and must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
|
||||
// Check that the storage supports API key management
|
||||
if (!storage.setApiKey) {
|
||||
res.status(400).json({ error: "API key management is not supported" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the provider is an API-key-backed provider
|
||||
const apiKeyProviders = storage.getApiKeyProviders?.() ?? [];
|
||||
const found = apiKeyProviders.find((p) => p.id === provider);
|
||||
if (!found) {
|
||||
res.status(400).json({ error: `Unknown API key provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setApiKey(provider, apiKey.trim());
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/api-key
|
||||
* Remove an API key for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
router.delete("/auth/api-key", (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
res.status(400).json({ error: "provider is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
if (!storage.clearApiKey) {
|
||||
res.status(400).json({ error: "API key management is not supported" });
|
||||
return;
|
||||
}
|
||||
|
||||
storage.clearApiKey(provider);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user