From 5738b7a3450c301de8444c44034f19ddaef1303a Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 15 May 2026 09:10:25 +0200 Subject: [PATCH 1/9] refactor(server): introduce provider abstraction and multi-account store Carve the GitHub-specific code (oauth device flow, identity fetch, gh-cli loader) out into a Provider plugin and move single-account token persistence to a new accountStore that supports N accounts plus ephemeral env-derived ones. On boot the store migrates a legacy auth.json into accounts.json (atomic write + .legacy.bak) so existing users see no change. The public surface of authProvider/oauth stays stable; tokenStore is marked deprecated. Behaviour for users with a single account is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/accountStore.ts | 280 ++++++++++++++++++++++++++++++ src/server/authProvider.ts | 18 +- src/server/oauth.ts | 203 ++++++++-------------- src/server/providers/github.ts | 226 ++++++++++++++++++++++++ src/server/providers/registry.ts | 35 ++++ src/server/providers/types.ts | 86 +++++++++ src/server/tokenStore.ts | 6 + tests/server/accountStore.test.ts | 153 ++++++++++++++++ 8 files changed, 866 insertions(+), 141 deletions(-) create mode 100644 src/server/accountStore.ts create mode 100644 src/server/providers/github.ts create mode 100644 src/server/providers/registry.ts create mode 100644 src/server/providers/types.ts create mode 100644 tests/server/accountStore.test.ts diff --git a/src/server/accountStore.ts b/src/server/accountStore.ts new file mode 100644 index 0000000..8070700 --- /dev/null +++ b/src/server/accountStore.ts @@ -0,0 +1,280 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { DATA_DIR } from "./config"; +import type { Account, AccountStoreData, ProviderConfig } from "./providers/types"; + +const ACCOUNTS_PATH = resolve(DATA_DIR, "accounts.json"); +const ACCOUNTS_TMP_PATH = resolve(DATA_DIR, "accounts.json.tmp"); +const LEGACY_TOKEN_PATH = resolve(DATA_DIR, "auth.json"); +const LEGACY_BACKUP_PATH = resolve(DATA_DIR, "auth.json.legacy.bak"); + +const DEFAULT_PROVIDER_CONFIGS: Record = { + "github.com": { + id: "github.com", + kind: "github", + label: "GitHub", + baseUrl: "https://api.github.com", + webUrl: "https://github.com", + graphqlUrl: "https://api.github.com/graphql", + oauthAuthorizeUrl: "https://github.com/login/oauth/authorize", + oauthDeviceCodeUrl: "https://github.com/login/device/code", + oauthTokenUrl: "https://github.com/login/oauth/access_token", + oauthScopes: "repo read:org project read:user user:email", + userAgent: "gh-issues-dashboard", + }, + "codeberg.org": { + id: "codeberg.org", + kind: "forgejo", + label: "Codeberg", + baseUrl: "https://codeberg.org/api/v1", + webUrl: "https://codeberg.org", + oauthAuthorizeUrl: "https://codeberg.org/login/oauth/authorize", + oauthTokenUrl: "https://codeberg.org/login/oauth/access_token", + oauthScopes: "read:repository read:notification read:user", + userAgent: "gh-issues-dashboard", + }, +}; + +interface InternalState { + persisted: AccountStoreData; + ephemeral: Account[]; +} + +let state: InternalState | null = null; +let initPromise: Promise | null = null; + +function emptyData(): AccountStoreData { + return { + version: 1, + activeId: null, + accounts: [], + providerConfigs: { ...DEFAULT_PROVIDER_CONFIGS }, + }; +} + +function mergeProviderConfigs( + configs: Record | undefined, +): Record { + return { ...DEFAULT_PROVIDER_CONFIGS, ...(configs ?? {}) }; +} + +async function readAccountsFile(): Promise { + try { + const raw = await readFile(ACCOUNTS_PATH, "utf-8"); + const parsed = JSON.parse(raw) as AccountStoreData; + if (parsed?.version !== 1 || !Array.isArray(parsed.accounts)) return null; + parsed.providerConfigs = mergeProviderConfigs(parsed.providerConfigs); + return parsed; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") return null; + throw error; + } +} + +interface LegacyToken { + accessToken: string; + scope?: string; + obtainedAt?: string; + login?: string; +} + +async function readLegacyToken(): Promise { + try { + const raw = await readFile(LEGACY_TOKEN_PATH, "utf-8"); + const parsed = JSON.parse(raw) as LegacyToken; + if (!parsed?.accessToken) return null; + return parsed; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") return null; + throw error; + } +} + +function migrateLegacy(legacy: LegacyToken): AccountStoreData { + const data = emptyData(); + const loginSlug = (legacy.login ?? "legacy").replace(/[^a-zA-Z0-9_-]/g, "_"); + const account: Account = { + id: `gh_${loginSlug}`, + providerKind: "github", + providerConfigId: "github.com", + label: legacy.login ? `${legacy.login} (github.com)` : "GitHub", + login: legacy.login ?? null, + accessToken: legacy.accessToken, + scope: legacy.scope ?? "", + obtainedAt: legacy.obtainedAt ?? new Date().toISOString(), + source: "device", + }; + data.accounts.push(account); + data.activeId = account.id; + return data; +} + +async function writePersisted(data: AccountStoreData): Promise { + await mkdir(DATA_DIR, { recursive: true }); + const payload = JSON.stringify(data, null, 2); + await writeFile(ACCOUNTS_TMP_PATH, payload, { mode: 0o600 }); + await rename(ACCOUNTS_TMP_PATH, ACCOUNTS_PATH); +} + +async function backupLegacy(): Promise { + try { + await rename(LEGACY_TOKEN_PATH, LEGACY_BACKUP_PATH); + } catch { + // best-effort + } +} + +async function doInit(): Promise { + const existing = await readAccountsFile(); + if (existing) { + state = { persisted: existing, ephemeral: [] }; + return; + } + const legacy = await readLegacyToken(); + if (legacy) { + const migrated = migrateLegacy(legacy); + await writePersisted(migrated); + await backupLegacy(); + state = { persisted: migrated, ephemeral: [] }; + return; + } + state = { persisted: emptyData(), ephemeral: [] }; +} + +export async function init(): Promise { + if (state) return; + if (!initPromise) { + initPromise = doInit().finally(() => { + initPromise = null; + }); + } + await initPromise; +} + +async function ensureState(): Promise { + if (!state) await init(); + if (!state) throw new Error("accountStore failed to initialise"); + return state; +} + +export async function list(): Promise { + const s = await ensureState(); + return [...s.persisted.accounts, ...s.ephemeral]; +} + +export async function get(id: string): Promise { + const all = await list(); + return all.find((account) => account.id === id) ?? null; +} + +export async function getActive(): Promise { + const s = await ensureState(); + const activeId = s.persisted.activeId; + if (activeId) { + const persisted = s.persisted.accounts.find((account) => account.id === activeId); + if (persisted) return persisted; + } + // If no persisted active, prefer an ephemeral env-based account. + if (s.ephemeral.length > 0) return s.ephemeral[0]; + return s.persisted.accounts[0] ?? null; +} + +export async function setActive(id: string): Promise { + const s = await ensureState(); + const target = + s.persisted.accounts.find((account) => account.id === id) ?? + s.ephemeral.find((account) => account.id === id); + if (!target) return null; + if (s.persisted.activeId !== id && !target.ephemeral) { + s.persisted.activeId = id; + await writePersisted(s.persisted); + } else if (target.ephemeral) { + // Cannot persist an ephemeral as active; just return it. + } + return target; +} + +export async function add(account: Account): Promise { + const s = await ensureState(); + if (account.ephemeral) { + const without = s.ephemeral.filter((existing) => existing.id !== account.id); + s.ephemeral = [...without, account]; + return account; + } + const without = s.persisted.accounts.filter((existing) => existing.id !== account.id); + s.persisted.accounts = [...without, account]; + if (!s.persisted.activeId) s.persisted.activeId = account.id; + await writePersisted(s.persisted); + return account; +} + +export async function update(id: string, patch: Partial): Promise { + const s = await ensureState(); + const idx = s.persisted.accounts.findIndex((account) => account.id === id); + if (idx >= 0) { + const merged = { ...s.persisted.accounts[idx], ...patch, id }; + s.persisted.accounts = [ + ...s.persisted.accounts.slice(0, idx), + merged, + ...s.persisted.accounts.slice(idx + 1), + ]; + await writePersisted(s.persisted); + return merged; + } + const eIdx = s.ephemeral.findIndex((account) => account.id === id); + if (eIdx >= 0) { + const merged = { ...s.ephemeral[eIdx], ...patch, id }; + s.ephemeral = [...s.ephemeral.slice(0, eIdx), merged, ...s.ephemeral.slice(eIdx + 1)]; + return merged; + } + return null; +} + +export async function remove(id: string): Promise { + const s = await ensureState(); + const before = s.persisted.accounts.length; + s.persisted.accounts = s.persisted.accounts.filter((account) => account.id !== id); + if (s.persisted.accounts.length !== before) { + if (s.persisted.activeId === id) { + s.persisted.activeId = s.persisted.accounts[0]?.id ?? null; + } + await writePersisted(s.persisted); + return true; + } + s.ephemeral = s.ephemeral.filter((account) => account.id !== id); + return false; +} + +export async function clear(): Promise { + const s = await ensureState(); + s.persisted = emptyData(); + s.ephemeral = []; + try { + await rm(ACCOUNTS_PATH, { force: true }); + } catch { + // best-effort + } +} + +export async function getProviderConfig(providerConfigId: string): Promise { + const s = await ensureState(); + return s.persisted.providerConfigs[providerConfigId] ?? null; +} + +export async function listProviderConfigs(): Promise> { + const s = await ensureState(); + return { ...s.persisted.providerConfigs }; +} + +export async function upsertProviderConfig(config: ProviderConfig): Promise { + const s = await ensureState(); + s.persisted.providerConfigs = { ...s.persisted.providerConfigs, [config.id]: config }; + await writePersisted(s.persisted); +} + +export function resetForTesting(): void { + state = null; + initPromise = null; +} diff --git a/src/server/authProvider.ts b/src/server/authProvider.ts index 0b7f50e..65155e6 100644 --- a/src/server/authProvider.ts +++ b/src/server/authProvider.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { readToken } from "./tokenStore"; +import { getActive as getActiveAccountFromStore, init as initAccountStore } from "./accountStore"; +import type { Account } from "./providers/types"; const execFileAsync = promisify(execFile); const USER_URL = "https://api.github.com/user"; @@ -93,6 +94,11 @@ async function loadEnvToken(): Promise { return envCache; } +export async function getActiveAccount(): Promise { + await initAccountStore(); + return getActiveAccountFromStore(); +} + export async function getActiveToken(): Promise { const mode = getAuthMode(); if (mode === "gh-cli") { @@ -103,8 +109,8 @@ export async function getActiveToken(): Promise { const cached = await loadEnvToken(); return cached.token; } - const stored = await readToken(); - return stored?.accessToken ?? null; + const account = await getActiveAccount(); + return account?.accessToken ?? null; } export async function getProviderStatus(): Promise { @@ -125,9 +131,9 @@ export async function getProviderStatus(): Promise { return { authenticated: false, login: null, scope: null, detail: (error as Error).message }; } } - const stored = await readToken(); - if (!stored) return { authenticated: false, login: null, scope: null }; - return { authenticated: true, login: stored.login ?? null, scope: stored.scope ?? null }; + const account = await getActiveAccount(); + if (!account) return { authenticated: false, login: null, scope: null }; + return { authenticated: true, login: account.login ?? null, scope: account.scope ?? null }; } export function resetExternalAuthCaches(): void { diff --git a/src/server/oauth.ts b/src/server/oauth.ts index e98548d..297adf9 100644 --- a/src/server/oauth.ts +++ b/src/server/oauth.ts @@ -1,43 +1,21 @@ +import { + add as addAccount, + init as initAccountStore, + list as listAccounts, + remove as removeAccount, +} from "./accountStore"; import { getAuthMode, getProviderStatus, resetExternalAuthCaches } from "./authProvider"; -import { clearToken, writeToken, type StoredToken } from "./tokenStore"; +import { getProvider } from "./providers/registry"; +import type { Account } from "./providers/types"; -const DEVICE_CODE_URL = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const USER_URL = "https://api.github.com/user"; - -const DEFAULT_SCOPES = "repo read:org project read:user user:email"; +const DEFAULT_PROVIDER_ID = "github.com"; interface PendingFlow { + providerConfigId: string; deviceCode: string; - interval: number; - expiresAt: number; } let pending: PendingFlow | null = null; -let lastPollAt = 0; - -function clientId(): string { - const id = process.env.GITHUB_CLIENT_ID?.trim(); - if (!id) { - throw new Error( - "GITHUB_CLIENT_ID is not set. Register an OAuth App at https://github.com/settings/developers " + - "(enable Device Flow) and export GITHUB_CLIENT_ID before starting the server." - ); - } - return id; -} - -function scopes(): string { - return process.env.GITHUB_OAUTH_SCOPES?.trim() || DEFAULT_SCOPES; -} - -interface DeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval: number; -} export interface DeviceFlowStartResult { userCode: string; @@ -46,51 +24,21 @@ export interface DeviceFlowStartResult { interval: number; } -export async function startDeviceFlow(): Promise { - const body = new URLSearchParams({ client_id: clientId(), scope: scopes() }); - const response = await fetch(DEVICE_CODE_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - const text = await response.text(); - let parsed: Partial = {}; - try { - parsed = JSON.parse(text); - } catch { - // Body is not JSON; surface the raw text in the error path below. - } - if (!response.ok || parsed.error) { - const detail = parsed.error_description || parsed.error || text || `HTTP ${response.status}`; - throw new Error(`GitHub device-code request failed: ${detail}`); - } - const data = parsed as DeviceCodeResponse; - pending = { - deviceCode: data.device_code, - interval: Math.max(5, data.interval || 5), - expiresAt: Date.now() + data.expires_in * 1000, - }; - lastPollAt = 0; +export async function startDeviceFlow( + providerConfigId: string = DEFAULT_PROVIDER_ID, +): Promise { + await initAccountStore(); + const provider = await getProvider(providerConfigId); + const result = await provider.startDeviceFlow(); + pending = { providerConfigId, deviceCode: result.deviceCode }; return { - userCode: data.user_code, - verificationUri: data.verification_uri, - expiresIn: data.expires_in, - interval: data.interval, + userCode: result.userCode, + verificationUri: result.verificationUri, + expiresIn: result.expiresIn, + interval: result.interval, }; } -interface AccessTokenResponse { - access_token?: string; - scope?: string; - token_type?: string; - error?: string; - error_description?: string; - interval?: number; -} - export type DeviceFlowPollResult = | { status: "pending" } | { status: "throttled" } @@ -99,80 +47,65 @@ export type DeviceFlowPollResult = | { status: "error"; error: string } | { status: "ok"; login: string }; -async function fetchUserLogin(token: string): Promise { - const response = await fetch(USER_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "gh-issues-dashboard", - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - throw new Error(`GitHub /user request failed: HTTP ${response.status}`); - } - const data = (await response.json()) as { login?: string }; - if (!data.login) throw new Error("GitHub /user response missing login"); - return data.login; +function buildAccount( + providerConfigId: string, + accessToken: string, + scope: string, + login: string, + kind: Account["providerKind"], + webHost: string, +): Account { + const safeLogin = (login || "user").replace(/[^a-zA-Z0-9_-]/g, "_"); + const prefix = kind === "github" ? "gh" : kind === "forgejo" ? "fj" : kind; + return { + id: `${prefix}_${safeLogin}_${providerConfigId}`, + providerKind: kind, + providerConfigId, + label: login ? `${login} (${webHost})` : webHost, + login: login || null, + accessToken, + scope, + obtainedAt: new Date().toISOString(), + source: "device", + }; } export async function pollDeviceFlow(): Promise { if (!pending) return { status: "error", error: "no pending device flow" }; - if (Date.now() >= pending.expiresAt) { + const provider = await getProvider(pending.providerConfigId); + const result = await provider.pollDeviceFlow(pending.deviceCode); + if (result.status === "ok") { + const webHost = new URL(provider.config.webUrl).host; + const account = buildAccount( + pending.providerConfigId, + result.accessToken, + result.scope, + result.login, + provider.kind, + webHost, + ); + await initAccountStore(); + await addAccount(account); pending = null; - return { status: "expired" }; + return { status: "ok", login: result.login }; } - const minInterval = pending.interval * 1000; - if (Date.now() - lastPollAt < minInterval) return { status: "throttled" }; - lastPollAt = Date.now(); - - const body = new URLSearchParams({ - client_id: clientId(), - device_code: pending.deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }); - const response = await fetch(ACCESS_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - const data = (await response.json()) as AccessTokenResponse; - - if (data.access_token) { - const login = await fetchUserLogin(data.access_token); - const stored: StoredToken = { - accessToken: data.access_token, - scope: data.scope ?? "", - obtainedAt: new Date().toISOString(), - login, - }; - await writeToken(stored); + if (result.status === "expired" || result.status === "denied") { pending = null; - return { status: "ok", login }; - } - - switch (data.error) { - case "authorization_pending": - return { status: "pending" }; - case "slow_down": - if (data.interval) pending.interval = Math.max(pending.interval, data.interval); - return { status: "throttled" }; - case "expired_token": - pending = null; - return { status: "expired" }; - case "access_denied": - pending = null; - return { status: "denied" }; - default: - return { status: "error", error: data.error_description || data.error || "unknown error" }; } + if (result.status === "throttled") return { status: "throttled" }; + if (result.status === "pending") return { status: "pending" }; + if (result.status === "expired") return { status: "expired" }; + if (result.status === "denied") return { status: "denied" }; + return { status: "error", error: result.error ?? "unknown error" }; } export async function logout(): Promise { pending = null; - await clearToken(); + await initAccountStore(); + const accounts = await listAccounts(); + for (const account of accounts) { + if (!account.ephemeral) await removeAccount(account.id); + } resetExternalAuthCaches(); } diff --git a/src/server/providers/github.ts b/src/server/providers/github.ts new file mode 100644 index 0000000..a9bbd85 --- /dev/null +++ b/src/server/providers/github.ts @@ -0,0 +1,226 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { + DeviceFlowPoll, + DeviceFlowStart, + Provider, + ProviderCapabilities, + ProviderConfig, + ProviderIdentity, +} from "./types"; + +const execFileAsync = promisify(execFile); + +const CAPABILITIES: ProviderCapabilities = { + graphql: true, + notifications: true, + projects: true, + ciWorkflows: true, + codeSearch: true, + dependents: true, + traffic: true, + stargazerHistory: true, +}; + +interface PendingFlow { + deviceCode: string; + interval: number; + expiresAt: number; +} + +export class GitHubProvider implements Provider { + readonly kind = "github" as const; + readonly capabilities = CAPABILITIES; + + private pending: PendingFlow | null = null; + private lastPollAt = 0; + + constructor(readonly config: ProviderConfig) {} + + private clientId(): string { + const id = this.config.oauthClientId ?? process.env.GITHUB_CLIENT_ID?.trim(); + if (!id) { + throw new Error( + "GITHUB_CLIENT_ID is not set. Register an OAuth App at https://github.com/settings/developers " + + "(enable Device Flow) and export GITHUB_CLIENT_ID before starting the server.", + ); + } + return id; + } + + private scopes(): string { + return ( + process.env.GITHUB_OAUTH_SCOPES?.trim() || + this.config.oauthScopes || + "repo read:org project read:user user:email" + ); + } + + async startDeviceFlow(): Promise { + const url = this.config.oauthDeviceCodeUrl; + if (!url) throw new Error(`Device flow URL not configured for ${this.config.id}`); + const body = new URLSearchParams({ client_id: this.clientId(), scope: this.scopes() }); + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + const text = await response.text(); + let parsed: { + device_code?: string; + user_code?: string; + verification_uri?: string; + expires_in?: number; + interval?: number; + error?: string; + error_description?: string; + } = {}; + try { + parsed = JSON.parse(text); + } catch { + // body not JSON + } + if (!response.ok || parsed.error || !parsed.device_code) { + const detail = parsed.error_description || parsed.error || text || `HTTP ${response.status}`; + throw new Error(`GitHub device-code request failed: ${detail}`); + } + this.pending = { + deviceCode: parsed.device_code, + interval: Math.max(5, parsed.interval || 5), + expiresAt: Date.now() + (parsed.expires_in ?? 900) * 1000, + }; + this.lastPollAt = 0; + return { + userCode: parsed.user_code ?? "", + verificationUri: parsed.verification_uri ?? "", + expiresIn: parsed.expires_in ?? 0, + interval: parsed.interval ?? 5, + deviceCode: parsed.device_code, + }; + } + + async pollDeviceFlow(deviceCode: string): Promise { + if (!this.pending || this.pending.deviceCode !== deviceCode) { + return { status: "error", error: "no pending device flow" }; + } + if (Date.now() >= this.pending.expiresAt) { + this.pending = null; + return { status: "expired" }; + } + const minInterval = this.pending.interval * 1000; + if (Date.now() - this.lastPollAt < minInterval) return { status: "throttled" }; + this.lastPollAt = Date.now(); + + const url = this.config.oauthTokenUrl; + if (!url) return { status: "error", error: "oauthTokenUrl not configured" }; + const body = new URLSearchParams({ + client_id: this.clientId(), + device_code: this.pending.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + const data = (await response.json()) as { + access_token?: string; + scope?: string; + error?: string; + error_description?: string; + interval?: number; + }; + + if (data.access_token) { + const identity = await this.fetchIdentity(data.access_token); + this.pending = null; + return { + status: "ok", + accessToken: data.access_token, + scope: data.scope ?? "", + login: identity.login, + }; + } + + switch (data.error) { + case "authorization_pending": + return { status: "pending" }; + case "slow_down": + if (data.interval) this.pending.interval = Math.max(this.pending.interval, data.interval); + return { status: "throttled", interval: data.interval }; + case "expired_token": + this.pending = null; + return { status: "expired" }; + case "access_denied": + this.pending = null; + return { status: "denied" }; + default: + return { status: "error", error: data.error_description || data.error || "unknown error" }; + } + } + + async fetchIdentity(token: string): Promise { + const response = await fetch(`${this.config.baseUrl}/user`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": this.config.userAgent, + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + return { login: "", scope: response.headers.get("x-oauth-scopes") }; + } + const data = (await response.json()) as { login?: string; avatar_url?: string; html_url?: string }; + return { + login: data.login ?? "", + scope: response.headers.get("x-oauth-scopes"), + avatarUrl: data.avatar_url ?? null, + htmlUrl: data.html_url ?? null, + }; + } + + async loadFromGhCli(): Promise<{ token: string } | null> { + try { + const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 5000 }); + const token = stdout.trim(); + if (!token) return null; + return { token }; + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: string }; + if (err.code === "ENOENT") { + throw new Error( + "gh CLI is not installed. Install it from https://cli.github.com/ or switch GH_AUTH_MODE.", + ); + } + const detail = err.stderr?.trim() || err.message; + throw new Error(`gh auth token failed: ${detail}. Run 'gh auth login' first.`); + } + } + + avatarUrl(login: string, size = 64): string { + return `${this.config.webUrl}/${encodeURIComponent(login)}.png?size=${size}`; + } + + webUrlFor( + kind: "user" | "repo" | "issue" | "pr", + parts: Record, + ): string { + const base = this.config.webUrl; + switch (kind) { + case "user": + return `${base}/${parts.login}`; + case "repo": + return `${base}/${parts.owner}/${parts.repo}`; + case "issue": + return `${base}/${parts.owner}/${parts.repo}/issues/${parts.number}`; + case "pr": + return `${base}/${parts.owner}/${parts.repo}/pull/${parts.number}`; + } + } +} diff --git a/src/server/providers/registry.ts b/src/server/providers/registry.ts new file mode 100644 index 0000000..aef085c --- /dev/null +++ b/src/server/providers/registry.ts @@ -0,0 +1,35 @@ +import { getProviderConfig } from "../accountStore"; +import { GitHubProvider } from "./github"; +import type { Account, Provider, ProviderConfig } from "./types"; + +const cache = new Map(); + +function build(config: ProviderConfig): Provider { + switch (config.kind) { + case "github": + return new GitHubProvider(config); + case "forgejo": + // Forgejo provider lands in PR5. Treat unknown as GitHub-compatible + // for the configurable bits but only the github.com config flows through + // this branch today. + throw new Error(`Provider kind 'forgejo' not yet implemented (configId=${config.id})`); + } +} + +export async function getProvider(providerConfigId: string): Promise { + const cached = cache.get(providerConfigId); + if (cached) return cached; + const config = await getProviderConfig(providerConfigId); + if (!config) throw new Error(`Unknown provider config: ${providerConfigId}`); + const provider = build(config); + cache.set(providerConfigId, provider); + return provider; +} + +export async function getProviderForAccount(account: Account): Promise { + return getProvider(account.providerConfigId); +} + +export function resetProviderCache(): void { + cache.clear(); +} diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts new file mode 100644 index 0000000..df148ca --- /dev/null +++ b/src/server/providers/types.ts @@ -0,0 +1,86 @@ +export type ProviderKind = "github" | "forgejo"; + +export type AccountSource = "device" | "gh-cli" | "token" | "env"; + +export interface ProviderConfig { + id: string; + kind: ProviderKind; + label: string; + baseUrl: string; + webUrl: string; + graphqlUrl?: string; + oauthAuthorizeUrl?: string; + oauthDeviceCodeUrl?: string; + oauthTokenUrl?: string; + oauthClientId?: string; + oauthScopes?: string; + userAgent: string; +} + +export interface ProviderCapabilities { + graphql: boolean; + notifications: boolean; + projects: boolean; + ciWorkflows: boolean; + codeSearch: boolean; + dependents: boolean; + traffic: boolean; + stargazerHistory: boolean; +} + +export interface Account { + id: string; + providerKind: ProviderKind; + providerConfigId: string; + label: string; + login: string | null; + accessToken: string; + scope: string; + obtainedAt: string; + source: AccountSource; + ephemeral?: boolean; +} + +export interface AccountStoreData { + version: 1; + activeId: string | null; + accounts: Account[]; + providerConfigs: Record; +} + +export interface DeviceFlowStart { + userCode: string; + verificationUri: string; + expiresIn: number; + interval: number; + deviceCode: string; +} + +export type DeviceFlowPoll = + | { status: "ok"; accessToken: string; scope: string; login: string } + | { status: "pending" } + | { status: "throttled"; interval?: number } + | { status: "expired" } + | { status: "denied" } + | { status: "error"; error: string }; + +export interface ProviderIdentity { + login: string; + scope: string | null; + avatarUrl?: string | null; + htmlUrl?: string | null; +} + +export interface Provider { + readonly kind: ProviderKind; + readonly config: ProviderConfig; + readonly capabilities: ProviderCapabilities; + + startDeviceFlow(): Promise; + pollDeviceFlow(deviceCode: string): Promise; + fetchIdentity(token: string): Promise; + loadFromGhCli?(): Promise<{ token: string } | null>; + + avatarUrl(login: string, size?: number): string; + webUrlFor(kind: "user" | "repo" | "issue" | "pr", parts: Record): string; +} diff --git a/src/server/tokenStore.ts b/src/server/tokenStore.ts index 67995ed..0754e96 100644 --- a/src/server/tokenStore.ts +++ b/src/server/tokenStore.ts @@ -2,6 +2,12 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { DATA_DIR } from "./config"; +/** + * @deprecated Legacy single-account token storage. New code must use + * `accountStore` instead. This module is kept to support backwards-compatible + * tooling and tests; production paths no longer call it. + */ + const TOKEN_PATH = resolve(DATA_DIR, "auth.json"); export interface StoredToken { diff --git a/tests/server/accountStore.test.ts b/tests/server/accountStore.test.ts new file mode 100644 index 0000000..5bec2fd --- /dev/null +++ b/tests/server/accountStore.test.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const { TMP_DIR } = vi.hoisted(() => { + const { tmpdir } = require("node:os") as typeof import("node:os"); + const { resolve } = require("node:path") as typeof import("node:path"); + return { + TMP_DIR: resolve(tmpdir(), `gh-dash-accountstore-${process.pid}-${Date.now()}`), + }; +}); + +vi.mock("../../src/server/config", () => ({ + DATA_DIR: TMP_DIR, +})); + +const store = await import("../../src/server/accountStore"); + +const ACCOUNTS_PATH = resolve(TMP_DIR, "accounts.json"); +const LEGACY_TOKEN_PATH = resolve(TMP_DIR, "auth.json"); +const LEGACY_BACKUP_PATH = resolve(TMP_DIR, "auth.json.legacy.bak"); + +async function writeLegacyToken(payload: Record): Promise { + await mkdir(TMP_DIR, { recursive: true }); + await writeFile(LEGACY_TOKEN_PATH, JSON.stringify(payload), { mode: 0o600 }); +} + +async function readAccountsFile(): Promise { + const raw = await readFile(ACCOUNTS_PATH, "utf-8"); + return JSON.parse(raw); +} + +async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +afterAll(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("accountStore", () => { + beforeEach(async () => { + store.resetForTesting(); + await rm(TMP_DIR, { recursive: true, force: true }); + }); + + it("bootstraps empty when no legacy file exists", async () => { + await store.init(); + const active = await store.getActive(); + expect(active).toBeNull(); + const all = await store.list(); + expect(all).toEqual([]); + expect(await fileExists(ACCOUNTS_PATH)).toBe(false); + }); + + it("migrates legacy auth.json into accounts.json and backs up the legacy file", async () => { + await writeLegacyToken({ + accessToken: "ghs_abc", + scope: "repo read:org", + obtainedAt: "2026-04-01T00:00:00Z", + login: "debba", + }); + + await store.init(); + + const active = await store.getActive(); + expect(active).not.toBeNull(); + expect(active?.accessToken).toBe("ghs_abc"); + expect(active?.login).toBe("debba"); + expect(active?.providerKind).toBe("github"); + expect(active?.providerConfigId).toBe("github.com"); + expect(active?.id).toBe("gh_debba"); + + expect(await fileExists(LEGACY_TOKEN_PATH)).toBe(false); + expect(await fileExists(LEGACY_BACKUP_PATH)).toBe(true); + + const data = (await readAccountsFile()) as { + activeId: string; + accounts: { id: string }[]; + providerConfigs: Record; + }; + expect(data.activeId).toBe("gh_debba"); + expect(data.accounts).toHaveLength(1); + expect(data.providerConfigs["github.com"]).toBeDefined(); + expect(data.providerConfigs["codeberg.org"]).toBeDefined(); + }); + + it("is idempotent across re-initializations", async () => { + await writeLegacyToken({ accessToken: "tok", scope: "repo", login: "alice" }); + await store.init(); + store.resetForTesting(); + await store.init(); + const all = await store.list(); + expect(all).toHaveLength(1); + expect(all[0].login).toBe("alice"); + }); + + it("supports add/setActive/remove on persisted accounts", async () => { + await store.init(); + const a = await store.add({ + id: "gh_one", + providerKind: "github", + providerConfigId: "github.com", + label: "one (github.com)", + login: "one", + accessToken: "t1", + scope: "repo", + obtainedAt: "2026-05-01T00:00:00Z", + source: "device", + }); + const b = await store.add({ + id: "gh_two", + providerKind: "github", + providerConfigId: "github.com", + label: "two (github.com)", + login: "two", + accessToken: "t2", + scope: "repo", + obtainedAt: "2026-05-02T00:00:00Z", + source: "device", + }); + expect((await store.getActive())?.id).toBe(a.id); + await store.setActive(b.id); + expect((await store.getActive())?.id).toBe(b.id); + await store.remove(b.id); + const active = await store.getActive(); + expect(active?.id).toBe(a.id); + }); + + it("does not persist ephemeral accounts", async () => { + await store.init(); + await store.add({ + id: "_env_token", + providerKind: "github", + providerConfigId: "github.com", + label: "env", + login: "env", + accessToken: "tok", + scope: "", + obtainedAt: "2026-05-01T00:00:00Z", + source: "env", + ephemeral: true, + }); + const all = await store.list(); + expect(all).toHaveLength(1); + expect(await fileExists(ACCOUNTS_PATH)).toBe(false); + }); +}); From 0a3e5e97d194d1822d1c25b85c35514f18919aa1 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 15 May 2026 09:10:40 +0200 Subject: [PATCH 2/9] feat(accounts): add /api/accounts endpoints and TopBar switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose GET /api/accounts, POST /api/accounts/activate and DELETE /api/accounts so the client can list, switch and remove stored accounts. Activate/remove invalidate the data, notifications and CI caches. The frontend wraps the app in an AccountProvider and renders an AccountSwitcher in the TopBar. The switcher stays hidden until at least two accounts exist, so users on a single account see no UI change yet — this lays the wiring for the multi-account flow that lands in the next step. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/github.ts | 45 ++++++++++++++ src/components/AccountSwitcher.tsx | 73 ++++++++++++++++++++++ src/components/TopBar.tsx | 2 + src/contexts/AccountContext.tsx | 97 ++++++++++++++++++++++++++++++ src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/i18n/es.ts | 2 + src/i18n/fr.ts | 2 + src/i18n/it.ts | 2 + src/i18n/zh.ts | 2 + src/main.tsx | 9 ++- src/server.ts | 94 +++++++++++++++++++++++++++++ src/styles/navigation.css | 49 ++++++++++++++- 13 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 src/components/AccountSwitcher.tsx create mode 100644 src/contexts/AccountContext.tsx diff --git a/src/api/github.ts b/src/api/github.ts index e33232c..cda6595 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -98,6 +98,51 @@ export function logoutAuth(): Promise<{ ok: true }> { return readJson<{ ok: true }>("/api/auth/logout", { method: "POST" }); } +export interface AccountSummary { + id: string; + providerKind: "github" | "forgejo"; + providerConfigId: string; + label: string; + login: string | null; + scope: string; + source: "device" | "gh-cli" | "token" | "env"; + ephemeral: boolean; + active: boolean; + capabilities: { + graphql?: boolean; + notifications?: boolean; + projects?: boolean; + ciWorkflows?: boolean; + codeSearch?: boolean; + dependents?: boolean; + traffic?: boolean; + stargazerHistory?: boolean; + }; +} + +export interface AccountsList { + ok: true; + accounts: AccountSummary[]; + activeId: string | null; +} + +export function fetchAccounts(): Promise { + return readJson("/api/accounts"); +} + +export function activateAccount(id: string): Promise<{ ok: true; activeId: string }> { + return readJson("/api/accounts/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); +} + +export function removeAccount(id: string): Promise<{ ok: true }> { + const query = new URLSearchParams({ id }); + return readJson(`/api/accounts?${query.toString()}`, { method: "DELETE" }); +} + export function fetchRepos(fresh = false, signal?: AbortSignal): Promise { return readJson(`/api/repos${fresh ? "?fresh=1" : ""}`, withSignal(signal), "/api/repos"); } diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx new file mode 100644 index 0000000..bd9d038 --- /dev/null +++ b/src/components/AccountSwitcher.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from "react"; +import { useAccounts } from "../contexts/AccountContext"; +import { useI18n } from "../i18n/I18nProvider"; + +export function AccountSwitcher() { + const { accounts, active, switchAccount } = useAccounts(); + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handlePointerDown(event: PointerEvent) { + if (!ref.current?.contains(event.target as Node)) setOpen(false); + } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + if (accounts.length <= 1) return null; + + return ( +
+ + {open ? ( +
+ {accounts.map((account) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 7a4c8be..7ab7566 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import appLogo from "../assets/app-logo-mark.svg"; import { useI18n } from "../i18n/I18nProvider"; import type { Language } from "../utils/i18n"; +import { AccountSwitcher } from "./AccountSwitcher"; type Theme = "dark" | "light" | "auto"; type TextSize = "small" | "normal" | "large"; @@ -86,6 +87,7 @@ export function TopBar({
{lastUpdated} + - {open ? ( -
- {accounts.map((account) => ( + <> +
+ + {open ? ( +
+ {accounts.map((account) => { + const isActive = account.id === active?.id; + const labelText = account.login ?? account.label; + return ( +
void handleSelect(account.id)} + > +
+ {labelText} + {!isActive && !account.ephemeral ? ( + + ) : null} +
+ {account.providerConfigId} +
+ ); + })} - ))} -
- ) : null} -
+
+ ) : null} +
+ setAddOpen(false)} /> + ); } diff --git a/src/components/AddAccountModal.tsx b/src/components/AddAccountModal.tsx new file mode 100644 index 0000000..7cb82ea --- /dev/null +++ b/src/components/AddAccountModal.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from "react"; +import { + pollAuthFlow, + startAuthFlow, + type DeviceFlowStart, +} from "../api/github"; +import { useI18n } from "../i18n/I18nProvider"; +import { useAccounts } from "../contexts/AccountContext"; + +interface AddAccountModalProps { + open: boolean; + onClose: () => void; +} + +type Phase = "idle" | "starting" | "awaiting" | "success" | "error"; + +export function AddAccountModal({ open, onClose }: AddAccountModalProps) { + const { t } = useI18n(); + const { refresh } = useAccounts(); + const [phase, setPhase] = useState("idle"); + const [flow, setFlow] = useState(null); + const [error, setError] = useState(""); + const [copied, setCopied] = useState(false); + const intervalRef = useRef(null); + const startedRef = useRef(false); + + function stopPolling() { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + + useEffect(() => { + if (!open) { + stopPolling(); + setPhase("idle"); + setFlow(null); + setError(""); + setCopied(false); + startedRef.current = false; + return; + } + if (startedRef.current) return; + startedRef.current = true; + void begin(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => () => stopPolling(), []); + + useEffect(() => { + if (!open) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [open, onClose]); + + async function poll() { + try { + const result = await pollAuthFlow(); + if (!("status" in result)) return; + if (result.status === "ok") { + stopPolling(); + setPhase("success"); + await refresh(); + window.setTimeout(() => onClose(), 800); + return; + } + if (result.status === "expired") { + stopPolling(); + setPhase("error"); + setError(t("auth.expired")); + return; + } + if (result.status === "denied") { + stopPolling(); + setPhase("error"); + setError(t("auth.denied")); + return; + } + if (result.status === "error") { + stopPolling(); + setPhase("error"); + setError(result.error); + } + } catch (err) { + stopPolling(); + setPhase("error"); + setError((err as Error).message); + } + } + + async function begin() { + setError(""); + setPhase("starting"); + try { + const data = await startAuthFlow(); + setFlow(data); + setPhase("awaiting"); + const intervalMs = Math.max(2, data.interval) * 1000; + intervalRef.current = window.setInterval(() => void poll(), intervalMs); + } catch (err) { + setPhase("error"); + setError((err as Error).message); + } + } + + async function copyCode() { + if (!flow) return; + try { + await navigator.clipboard.writeText(flow.userCode); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard unavailable; users can copy manually + } + } + + function retry() { + startedRef.current = true; + void begin(); + } + + if (!open) return null; + + return ( +
+
+
+

{t("accounts.add")}

+ +
+ + {phase === "starting" ? ( +

{t("auth.requestingCode")}

+ ) : null} + + {phase === "awaiting" && flow ? ( +
+

{t("auth.openVerification")}

+ + {flow.verificationUri} + +
+ {flow.userCode} + +
+

{t("auth.waiting")}

+
+ ) : null} + + {phase === "success" ?

{t("accounts.added")}

: null} + + {phase === "error" ? ( + <> +

{error}

+ + + ) : null} +
+
+ ); +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index dec3914..9b3eaa5 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -22,6 +22,10 @@ export const de: Record = { "common.signedIn": "Angemeldet", "accounts.switch": "Konto wechseln", "accounts.select": "Konto auswählen", + "accounts.add": "Konto hinzufügen", + "accounts.added": "Konto hinzugefügt. Aktualisieren…", + "accounts.remove": "{name} entfernen", + "accounts.removeConfirm": "{name} aus diesem Dashboard entfernen?", "common.authenticated": "Authentifiziert", "common.authenticatedExternally": "Extern authentifiziert", "common.export": "Exportieren", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d7a4963..b90ff82 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -20,6 +20,10 @@ export const en = { "common.signedIn": "Signed in", "accounts.switch": "Switch account", "accounts.select": "Select account", + "accounts.add": "Add account", + "accounts.added": "Account added. Refreshing…", + "accounts.remove": "Remove {name}", + "accounts.removeConfirm": "Remove {name} from this dashboard?", "common.authenticated": "Authenticated", "common.authenticatedExternally": "Authenticated externally", "common.export": "Export", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 66ebc24..22357ad 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -22,6 +22,10 @@ export const es: Record = { "common.signedIn": "Sesión iniciada", "accounts.switch": "Cambiar de cuenta", "accounts.select": "Seleccionar cuenta", + "accounts.add": "Añadir cuenta", + "accounts.added": "Cuenta añadida. Actualizando…", + "accounts.remove": "Eliminar {name}", + "accounts.removeConfirm": "¿Eliminar {name} de este panel?", "common.authenticated": "Autenticado", "common.authenticatedExternally": "Autenticado externamente", "common.export": "Exportar", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index e8189cc..f991f6d 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -22,6 +22,10 @@ export const fr: Record = { "common.signedIn": "Connecté", "accounts.switch": "Changer de compte", "accounts.select": "Sélectionner un compte", + "accounts.add": "Ajouter un compte", + "accounts.added": "Compte ajouté. Actualisation…", + "accounts.remove": "Supprimer {name}", + "accounts.removeConfirm": "Supprimer {name} de ce tableau de bord ?", "common.authenticated": "Authentifié", "common.authenticatedExternally": "Authentifié en externe", "common.export": "Exporter", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index e6ab764..24916ce 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -22,6 +22,10 @@ export const it: Record = { "common.signedIn": "Accesso effettuato", "accounts.switch": "Cambia account", "accounts.select": "Seleziona account", + "accounts.add": "Aggiungi account", + "accounts.added": "Account aggiunto. Aggiornamento…", + "accounts.remove": "Rimuovi {name}", + "accounts.removeConfirm": "Rimuovere {name} da questa dashboard?", "common.authenticated": "Autenticato", "common.authenticatedExternally": "Autenticato esternamente", "common.export": "Esporta", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 1b88601..b3f3ae7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -22,6 +22,10 @@ export const zh: Record = { "common.signedIn": "已登录", "accounts.switch": "切换账户", "accounts.select": "选择账户", + "accounts.add": "添加账户", + "accounts.added": "账户已添加,正在刷新…", + "accounts.remove": "移除 {name}", + "accounts.removeConfirm": "从此面板移除 {name}?", "common.authenticated": "已认证", "common.authenticatedExternally": "外部认证", "common.export": "导出", diff --git a/src/styles/navigation.css b/src/styles/navigation.css index bf0a292..0298f3f 100644 --- a/src/styles/navigation.css +++ b/src/styles/navigation.css @@ -97,10 +97,9 @@ .account-switcher-item { display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; gap: 2px; padding: 7px 9px; - border: 0; border-radius: 6px; background: transparent; color: var(--text); @@ -114,6 +113,12 @@ .account-switcher-item.active { background: color-mix(in srgb, var(--accent) 18%, var(--panel-2)); } + .account-switcher-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } .account-switcher-label { font-weight: 700; } @@ -121,6 +126,82 @@ color: var(--muted); font-size: 11px; } + .account-switcher-remove { + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0 4px; + font-size: 16px; + line-height: 1; + border-radius: 4px; + } + .account-switcher-remove:hover { + background: color-mix(in srgb, var(--danger, #f87171) 20%, transparent); + color: var(--danger, #f87171); + } + .account-switcher-add { + margin-top: 4px; + padding: 8px 9px; + border: 0; + border-top: 1px solid var(--border-soft); + border-radius: 0 0 6px 6px; + background: transparent; + color: var(--accent); + cursor: pointer; + text-align: left; + font-size: 12.5px; + font-weight: 700; + } + .account-switcher-add:hover { + background: var(--panel-2); + } + .add-account-backdrop { + position: fixed; + inset: 0; + background: color-mix(in srgb, black 55%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 80; + padding: 16px; + } + .add-account-card { + width: min(420px, 100%); + padding: 20px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--panel); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 12px; + } + .add-account-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .add-account-header h2 { + margin: 0; + font-size: 16px; + font-weight: 700; + } + .add-account-close { + border: 0; + background: transparent; + color: var(--muted); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0 6px; + border-radius: 4px; + } + .add-account-close:hover { + background: var(--panel-2); + color: var(--text); + } .preferences-btn.active { background: var(--panel-2); border-color: var(--button-hover-border); From 9a5b1c199accaad120a89451c5ee6934bd3dd84c Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 15 May 2026 09:17:51 +0200 Subject: [PATCH 4/9] feat(accounts): gate the kanban tab behind provider capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a useCapability hook that reads the active account's capabilities exposed by /api/accounts and use it in App.tsx to hide the Projects (kanban) tab when the provider doesn't support it. Today every account is GitHub with full capabilities so nothing changes visibly — this just lays the wiring so the Forgejo provider can opt out of Projects, Dependents and Code Search without touching App.tsx again. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 8 ++++++-- src/contexts/AccountContext.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 98920d8..0fcb352 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,7 @@ import { formatNumber } from "./utils/format"; import { clearStatsCache, readStatsCache, writeStatsCache } from "./utils/statsCache"; import { clearFiltersCache, hydrateFilters, readFiltersCache, writeFiltersCache } from "./utils/filtersCache"; import { useI18n } from "./i18n/I18nProvider"; +import { useCapability } from "./contexts/AccountContext"; type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "ci" | "digests"; type Theme = "dark" | "light" | "auto"; @@ -159,6 +160,7 @@ type AuthState = "checking" | "anonymous" | "authenticated"; export function App() { const { t } = useI18n(); + const projectsEnabled = useCapability("projects"); const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -657,7 +659,9 @@ export function App() { { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, icon: }, { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, icon: }, { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, icon: }, - { key: "kanban" as const, label: t("tabs.board"), count: "—", icon: }, + ...(projectsEnabled + ? [{ key: "kanban" as const, label: t("tabs.board"), count: "—", icon: }] + : []), ]; return ( @@ -885,7 +889,7 @@ export function App() {
) : null} - {tab === "kanban" ? : null} + {tab === "kanban" && projectsEnabled ? : null}