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 (
+
+
setOpen((value) => !value)}
+ >
+
+
+
+
+
+
+ {active?.login ?? active?.label ?? t("accounts.select")}
+
+ {open ? (
+
+ {accounts.map((account) => (
+ {
+ setOpen(false);
+ try {
+ await switchAccount(account.id);
+ } catch {
+ // refresh effect will surface the error
+ }
+ }}
+ >
+ {account.login ?? account.label}
+ {account.providerConfigId}
+
+ ))}
+
+ ) : 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}
+
{t("common.search")}
diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx
new file mode 100644
index 0000000..a4e5888
--- /dev/null
+++ b/src/contexts/AccountContext.tsx
@@ -0,0 +1,97 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ activateAccount,
+ fetchAccounts,
+ removeAccount as removeAccountApi,
+ type AccountSummary,
+} from "../api/github";
+import { invalidate as invalidateClientCache } from "../api/cache";
+
+interface AccountContextValue {
+ accounts: AccountSummary[];
+ active: AccountSummary | null;
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+ switchAccount: (id: string) => Promise;
+ removeAccount: (id: string) => Promise;
+}
+
+const AccountContext = createContext(null);
+
+export function AccountProvider({ children }: { children: ReactNode }) {
+ const [accounts, setAccounts] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const mounted = useRef(true);
+
+ const refresh = useCallback(async () => {
+ try {
+ const data = await fetchAccounts();
+ if (!mounted.current) return;
+ setAccounts(data.accounts);
+ setActiveId(data.activeId);
+ setError(null);
+ } catch (err) {
+ if (!mounted.current) return;
+ setError((err as Error).message);
+ } finally {
+ if (mounted.current) setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+ void refresh();
+ return () => {
+ mounted.current = false;
+ };
+ }, [refresh]);
+
+ const switchAccount = useCallback(
+ async (id: string) => {
+ if (id === activeId) return;
+ await activateAccount(id);
+ invalidateClientCache();
+ await refresh();
+ },
+ [activeId, refresh],
+ );
+
+ const removeAccount = useCallback(
+ async (id: string) => {
+ await removeAccountApi(id);
+ invalidateClientCache();
+ await refresh();
+ },
+ [refresh],
+ );
+
+ const active = useMemo(
+ () => accounts.find((account) => account.id === activeId) ?? null,
+ [accounts, activeId],
+ );
+
+ const value = useMemo(
+ () => ({ accounts, active, loading, error, refresh, switchAccount, removeAccount }),
+ [accounts, active, loading, error, refresh, switchAccount, removeAccount],
+ );
+
+ return {children} ;
+}
+
+export function useAccounts(): AccountContextValue {
+ const value = useContext(AccountContext);
+ if (!value) throw new Error("useAccounts must be used inside AccountProvider");
+ return value;
+}
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 091d000..dec3914 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -20,6 +20,8 @@ export const de: Record = {
"common.closeFilters": "Filter schließen",
"common.signOut": "Abmelden",
"common.signedIn": "Angemeldet",
+ "accounts.switch": "Konto wechseln",
+ "accounts.select": "Konto auswählen",
"common.authenticated": "Authentifiziert",
"common.authenticatedExternally": "Extern authentifiziert",
"common.export": "Exportieren",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 9b05ab6..d7a4963 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -18,6 +18,8 @@ export const en = {
"common.closeFilters": "Close filters",
"common.signOut": "Sign out",
"common.signedIn": "Signed in",
+ "accounts.switch": "Switch account",
+ "accounts.select": "Select account",
"common.authenticated": "Authenticated",
"common.authenticatedExternally": "Authenticated externally",
"common.export": "Export",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 4f8ab1b..66ebc24 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -20,6 +20,8 @@ export const es: Record = {
"common.closeFilters": "Cerrar filtros",
"common.signOut": "Cerrar sesión",
"common.signedIn": "Sesión iniciada",
+ "accounts.switch": "Cambiar de cuenta",
+ "accounts.select": "Seleccionar cuenta",
"common.authenticated": "Autenticado",
"common.authenticatedExternally": "Autenticado externamente",
"common.export": "Exportar",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 61943da..e8189cc 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -20,6 +20,8 @@ export const fr: Record = {
"common.closeFilters": "Fermer les filtres",
"common.signOut": "Se déconnecter",
"common.signedIn": "Connecté",
+ "accounts.switch": "Changer de compte",
+ "accounts.select": "Sélectionner un compte",
"common.authenticated": "Authentifié",
"common.authenticatedExternally": "Authentifié en externe",
"common.export": "Exporter",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 4c243e7..e6ab764 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -20,6 +20,8 @@ export const it: Record = {
"common.closeFilters": "Chiudi filtri",
"common.signOut": "Esci",
"common.signedIn": "Accesso effettuato",
+ "accounts.switch": "Cambia account",
+ "accounts.select": "Seleziona account",
"common.authenticated": "Autenticato",
"common.authenticatedExternally": "Autenticato esternamente",
"common.export": "Esporta",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index d95aeb7..1b88601 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -20,6 +20,8 @@ export const zh: Record = {
"common.closeFilters": "关闭筛选",
"common.signOut": "退出登录",
"common.signedIn": "已登录",
+ "accounts.switch": "切换账户",
+ "accounts.select": "选择账户",
"common.authenticated": "已认证",
"common.authenticatedExternally": "外部认证",
"common.export": "导出",
diff --git a/src/main.tsx b/src/main.tsx
index b901a0c..8f4fe06 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,13 +1,16 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
+import { AccountProvider } from "./contexts/AccountContext";
import { I18nProvider } from "./i18n/I18nProvider";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/src/server.ts b/src/server.ts
index ffb860d..d1f2ea3 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -27,6 +27,14 @@ import {
startDeviceFlow,
} from "./server/oauth";
import { getAuthMode } from "./server/authProvider";
+import {
+ getActive as getActiveAccount,
+ init as initAccountStore,
+ list as listAccountsStore,
+ remove as removeAccountStore,
+ setActive as setActiveAccount,
+} from "./server/accountStore";
+import { getProviderForAccount } from "./server/providers/registry";
import { send, sendJson, sendJsonCacheable, sendStaticFile } from "./server/http";
import {
getNotificationsCached,
@@ -838,6 +846,85 @@ async function handleAuthLogout(req: IncomingMessage, res: ServerResponse): Prom
sendJson(res, 200, { ok: true });
}
+/* ===================== ACCOUNTS ===================== */
+
+interface AccountSummary {
+ id: string;
+ providerKind: string;
+ providerConfigId: string;
+ label: string;
+ login: string | null;
+ scope: string;
+ source: string;
+ ephemeral: boolean;
+ active: boolean;
+ capabilities: Record;
+}
+
+async function summariseAccount(account: Awaited>, activeId: string | null): Promise {
+ if (!account) return null;
+ let capabilities: Record = {};
+ try {
+ const provider = await getProviderForAccount(account);
+ capabilities = { ...provider.capabilities };
+ } catch {
+ // Unknown provider kind — return empty caps; UI will treat as conservative.
+ }
+ return {
+ id: account.id,
+ providerKind: account.providerKind,
+ providerConfigId: account.providerConfigId,
+ label: account.label,
+ login: account.login,
+ scope: account.scope,
+ source: account.source,
+ ephemeral: Boolean(account.ephemeral),
+ active: account.id === activeId,
+ capabilities,
+ };
+}
+
+async function handleAccountsList(res: ServerResponse): Promise {
+ await initAccountStore();
+ const all = await listAccountsStore();
+ const active = await getActiveAccount();
+ const summaries: AccountSummary[] = [];
+ for (const account of all) {
+ const summary = await summariseAccount(account, active?.id ?? null);
+ if (summary) summaries.push(summary);
+ }
+ sendJson(res, 200, { ok: true, accounts: summaries, activeId: active?.id ?? null });
+}
+
+async function handleAccountActivate(req: IncomingMessage, res: ServerResponse): Promise {
+ if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "POST required" });
+ let parsed: { id?: string };
+ try { parsed = (await readJsonBody(req)) as { id?: string }; }
+ catch { return sendJson(res, 400, { ok: false, error: "invalid JSON" }); }
+ const id = (parsed.id || "").trim();
+ if (!id) return sendJson(res, 400, { ok: false, error: "missing id" });
+ await initAccountStore();
+ const account = await setActiveAccount(id);
+ if (!account) return sendJson(res, 404, { ok: false, error: "account not found" });
+ invalidateDataCache();
+ invalidateNotificationsCache();
+ invalidateCIHealthCache();
+ sendJson(res, 200, { ok: true, activeId: account.id });
+}
+
+async function handleAccountRemove(req: IncomingMessage, res: ServerResponse, u: URL): Promise {
+ if (req.method !== "DELETE") return sendJson(res, 405, { ok: false, error: "DELETE required" });
+ const id = (u.searchParams.get("id") || "").trim();
+ if (!id) return sendJson(res, 400, { ok: false, error: "missing id" });
+ await initAccountStore();
+ const existed = await removeAccountStore(id);
+ if (!existed) return sendJson(res, 404, { ok: false, error: "account not found" });
+ invalidateDataCache();
+ invalidateNotificationsCache();
+ invalidateCIHealthCache();
+ sendJson(res, 200, { ok: true });
+}
+
/* ===================== NOTIFICATIONS ===================== */
async function handleNotifications(req: IncomingMessage, res: ServerResponse, u: URL): Promise {
@@ -942,6 +1029,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise
if (url.startsWith("/api/auth/start")) return handleAuthStart(req, res);
if (url.startsWith("/api/auth/poll")) return handleAuthPoll(req, res);
if (url.startsWith("/api/auth/logout")) return handleAuthLogout(req, res);
+ if (url.startsWith("/api/accounts/activate")) return handleAccountActivate(req, res);
+ if (url.startsWith("/api/accounts")) {
+ if (req.method === "DELETE") {
+ return handleAccountRemove(req, res, new URL(url, "http://localhost"));
+ }
+ return handleAccountsList(res);
+ }
if (url.startsWith("/api/stargazers")) {
return handleStargazers(res, new URL(url, "http://localhost"));
}
diff --git a/src/styles/navigation.css b/src/styles/navigation.css
index fbb32a3..bf0a292 100644
--- a/src/styles/navigation.css
+++ b/src/styles/navigation.css
@@ -71,9 +71,56 @@
white-space: nowrap;
border: 0;
}
- .preferences-menu {
+ .preferences-menu,
+ .account-switcher {
position: relative;
}
+ .account-switcher-btn.active {
+ background: var(--panel-2);
+ border-color: var(--button-hover-border);
+ }
+ .account-switcher-popover {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ min-width: 220px;
+ padding: 6px;
+ border: 1px solid var(--border-soft);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--panel) 96%, black);
+ box-shadow: var(--shadow);
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .account-switcher-item {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 7px 9px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ text-align: left;
+ font-size: 12.5px;
+ }
+ .account-switcher-item:hover {
+ background: var(--panel-2);
+ }
+ .account-switcher-item.active {
+ background: color-mix(in srgb, var(--accent) 18%, var(--panel-2));
+ }
+ .account-switcher-label {
+ font-weight: 700;
+ }
+ .account-switcher-meta {
+ color: var(--muted);
+ font-size: 11px;
+ }
.preferences-btn.active {
background: var(--panel-2);
border-color: var(--button-hover-border);
From abe591e09e34f27461357d2010a7d9ce225b7a3f Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 09:13:55 +0200
Subject: [PATCH 3/9] feat(accounts): add and remove GitHub accounts from the
switcher
The TopBar switcher is now always visible. From its dropdown the user
can kick off a fresh device flow to add another GitHub account (modal
reusing /api/auth/start + /api/auth/poll), and remove any inactive
non-ephemeral account with a confirm prompt.
Adding an account triggers a context refresh; removing one invalidates
the server-side caches via DELETE /api/accounts. Switching between
accounts uses the wiring landed in the previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/components/AccountSwitcher.tsx | 119 +++++++++++++-------
src/components/AddAccountModal.tsx | 167 +++++++++++++++++++++++++++++
src/i18n/de.ts | 4 +
src/i18n/en.ts | 4 +
src/i18n/es.ts | 4 +
src/i18n/fr.ts | 4 +
src/i18n/it.ts | 4 +
src/i18n/zh.ts | 4 +
src/styles/navigation.css | 85 ++++++++++++++-
9 files changed, 355 insertions(+), 40 deletions(-)
create mode 100644 src/components/AddAccountModal.tsx
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index bd9d038..ea289fd 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useAccounts } from "../contexts/AccountContext";
import { useI18n } from "../i18n/I18nProvider";
+import { AddAccountModal } from "./AddAccountModal";
export function AccountSwitcher() {
- const { accounts, active, switchAccount } = useAccounts();
+ const { accounts, active, switchAccount, removeAccount } = useAccounts();
const { t } = useI18n();
const [open, setOpen] = useState(false);
+ const [addOpen, setAddOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
@@ -24,50 +26,91 @@ export function AccountSwitcher() {
};
}, [open]);
- if (accounts.length <= 1) return null;
+ if (accounts.length === 0) return null;
+
+ async function handleSelect(id: string) {
+ setOpen(false);
+ try {
+ await switchAccount(id);
+ } catch {
+ // refresh effect surfaces the error
+ }
+ }
+
+ async function handleRemove(event: React.MouseEvent, id: string, label: string) {
+ event.stopPropagation();
+ if (!window.confirm(t("accounts.removeConfirm").replace("{name}", label))) return;
+ try {
+ await removeAccount(id);
+ } catch {
+ // refresh effect surfaces the error
+ }
+ }
return (
-
-
setOpen((value) => !value)}
- >
-
-
-
-
-
-
- {active?.login ?? active?.label ?? t("accounts.select")}
-
- {open ? (
-
- {accounts.map((account) => (
+ <>
+
+
setOpen((value) => !value)}
+ >
+
+
+
+
+
+
+ {active?.login ?? active?.label ?? t("accounts.select")}
+
+ {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 ? (
+ void handleRemove(event, account.id, labelText)}
+ >
+ ×
+
+ ) : null}
+
+
{account.providerConfigId}
+
+ );
+ })}
{
+ className="account-switcher-add"
+ onClick={() => {
setOpen(false);
- try {
- await switchAccount(account.id);
- } catch {
- // refresh effect will surface the error
- }
+ setAddOpen(true);
}}
>
- {account.login ?? account.label}
- {account.providerConfigId}
+ + {t("accounts.add")}
- ))}
-
- ) : 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}
+ void copyCode()}>
+ {copied ? t("auth.copied") : t("auth.copy")}
+
+
+
{t("auth.waiting")}
+
+ ) : null}
+
+ {phase === "success" ?
{t("accounts.added")}
: null}
+
+ {phase === "error" ? (
+ <>
+
{error}
+
{t("auth.continue")}
+ >
+ ) : 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}
;
+
+export function useCapability(name: CapabilityName, fallback = true): boolean {
+ const { active } = useAccounts();
+ if (!active) return fallback;
+ const value = active.capabilities?.[name];
+ return value === undefined ? fallback : value;
+}
From 0f7157c4e8c1358569378a037b72b125daf60ed0 Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 09:22:14 +0200
Subject: [PATCH 5/9] feat(forgejo): provider skeleton and token-based account
onboarding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add the Forgejo provider (Codeberg config baked in) that authenticates
via personal access token: fetchIdentity hits /api/v1/user with a
"token …" header, capabilities declare GraphQL/Projects/Dependents
unavailable so the UI can hide what doesn't apply.
Expose POST /api/accounts/add-token and GET /api/provider-configs.
The Add account modal becomes a two-step flow: pick a provider, then
either run the existing GitHub device flow or paste a token for
Codeberg. The new account lands in accounts.json next to any GitHub
one already configured.
Data fetching for a Forgejo-active account still falls back to the
GitHub code path (next commit re-routes it through the provider).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/api/github.ts | 20 ++++
src/components/AddAccountModal.tsx | 159 +++++++++++++++++++++--------
src/i18n/de.ts | 6 ++
src/i18n/en.ts | 6 ++
src/i18n/es.ts | 6 ++
src/i18n/fr.ts | 6 ++
src/i18n/it.ts | 6 ++
src/i18n/zh.ts | 6 ++
src/server.ts | 60 ++++++++++-
src/server/providers/forgejo.ts | 85 +++++++++++++++
src/server/providers/registry.ts | 6 +-
src/styles/navigation.css | 55 ++++++++++
12 files changed, 374 insertions(+), 47 deletions(-)
create mode 100644 src/server/providers/forgejo.ts
diff --git a/src/api/github.ts b/src/api/github.ts
index cda6595..59c125f 100644
--- a/src/api/github.ts
+++ b/src/api/github.ts
@@ -143,6 +143,26 @@ export function removeAccount(id: string): Promise<{ ok: true }> {
return readJson(`/api/accounts?${query.toString()}`, { method: "DELETE" });
}
+export interface ProviderConfigSummary {
+ id: string;
+ kind: "github" | "forgejo";
+ label: string;
+ webUrl: string;
+ supportsDeviceFlow: boolean;
+}
+
+export function fetchProviderConfigs(): Promise<{ ok: true; configs: ProviderConfigSummary[] }> {
+ return readJson<{ ok: true; configs: ProviderConfigSummary[] }>("/api/provider-configs");
+}
+
+export function addTokenAccount(payload: { providerConfigId: string; token: string; label?: string }): Promise<{ ok: true; accountId: string }> {
+ return readJson("/api/accounts/add-token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+}
+
export function fetchRepos(fresh = false, signal?: AbortSignal): Promise {
return readJson(`/api/repos${fresh ? "?fresh=1" : ""}`, withSignal(signal), "/api/repos");
}
diff --git a/src/components/AddAccountModal.tsx b/src/components/AddAccountModal.tsx
index 7cb82ea..d73d137 100644
--- a/src/components/AddAccountModal.tsx
+++ b/src/components/AddAccountModal.tsx
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from "react";
import {
+ addTokenAccount,
+ fetchProviderConfigs,
pollAuthFlow,
startAuthFlow,
type DeviceFlowStart,
+ type ProviderConfigSummary,
} from "../api/github";
import { useI18n } from "../i18n/I18nProvider";
import { useAccounts } from "../contexts/AccountContext";
@@ -12,17 +15,22 @@ interface AddAccountModalProps {
onClose: () => void;
}
-type Phase = "idle" | "starting" | "awaiting" | "success" | "error";
+type Step = "choose" | "device" | "token" | "success";
+type DevicePhase = "starting" | "awaiting" | "error";
export function AddAccountModal({ open, onClose }: AddAccountModalProps) {
const { t } = useI18n();
const { refresh } = useAccounts();
- const [phase, setPhase] = useState("idle");
+ const [configs, setConfigs] = useState([]);
+ const [step, setStep] = useState("choose");
+ const [selected, setSelected] = useState(null);
const [flow, setFlow] = useState(null);
+ const [devicePhase, setDevicePhase] = useState("starting");
+ const [token, setToken] = useState("");
+ const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
const intervalRef = useRef(null);
- const startedRef = useRef(false);
function stopPolling() {
if (intervalRef.current !== null) {
@@ -34,17 +42,19 @@ export function AddAccountModal({ open, onClose }: AddAccountModalProps) {
useEffect(() => {
if (!open) {
stopPolling();
- setPhase("idle");
+ setStep("choose");
+ setSelected(null);
setFlow(null);
+ setToken("");
setError("");
setCopied(false);
- startedRef.current = false;
+ setSubmitting(false);
return;
}
- if (startedRef.current) return;
- startedRef.current = true;
- void begin();
- }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
+ void fetchProviderConfigs()
+ .then((res) => setConfigs(res.configs))
+ .catch((err) => setError((err as Error).message));
+ }, [open]);
useEffect(() => () => stopPolling(), []);
@@ -57,52 +67,58 @@ export function AddAccountModal({ open, onClose }: AddAccountModalProps) {
return () => window.removeEventListener("keydown", handleKey);
}, [open, onClose]);
+ async function pickProvider(config: ProviderConfigSummary) {
+ setError("");
+ setSelected(config);
+ if (config.supportsDeviceFlow) {
+ setStep("device");
+ setDevicePhase("starting");
+ try {
+ const data = await startAuthFlow();
+ setFlow(data);
+ setDevicePhase("awaiting");
+ const intervalMs = Math.max(2, data.interval) * 1000;
+ intervalRef.current = window.setInterval(() => void poll(), intervalMs);
+ } catch (err) {
+ setDevicePhase("error");
+ setError((err as Error).message);
+ }
+ } else {
+ setStep("token");
+ }
+ }
+
async function poll() {
try {
const result = await pollAuthFlow();
if (!("status" in result)) return;
if (result.status === "ok") {
stopPolling();
- setPhase("success");
+ setStep("success");
await refresh();
window.setTimeout(() => onClose(), 800);
return;
}
if (result.status === "expired") {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError(t("auth.expired"));
return;
}
if (result.status === "denied") {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError(t("auth.denied"));
return;
}
if (result.status === "error") {
stopPolling();
- setPhase("error");
+ setDevicePhase("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");
+ setDevicePhase("error");
setError((err as Error).message);
}
}
@@ -114,13 +130,30 @@ export function AddAccountModal({ open, onClose }: AddAccountModalProps) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
- // clipboard unavailable; users can copy manually
+ // clipboard unavailable
}
}
- function retry() {
- startedRef.current = true;
- void begin();
+ async function submitToken(event: React.FormEvent) {
+ event.preventDefault();
+ if (!selected) return;
+ const trimmed = token.trim();
+ if (!trimmed) {
+ setError(t("accounts.tokenRequired"));
+ return;
+ }
+ setError("");
+ setSubmitting(true);
+ try {
+ await addTokenAccount({ providerConfigId: selected.id, token: trimmed });
+ setStep("success");
+ await refresh();
+ window.setTimeout(() => onClose(), 800);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setSubmitting(false);
+ }
}
if (!open) return null;
@@ -133,11 +166,33 @@ export function AddAccountModal({ open, onClose }: AddAccountModalProps) {
×
- {phase === "starting" ? (
+ {step === "choose" ? (
+
+
{t("accounts.pickProvider")}
+ {configs.length === 0 && !error ?
{t("common.loading")}
: null}
+ {configs.map((config) => (
+
void pickProvider(config)}
+ >
+ {config.label}
+
+ {config.kind === "github" && config.supportsDeviceFlow
+ ? t("accounts.viaDeviceFlow")
+ : t("accounts.viaToken")}
+
+
+ ))}
+
+ ) : null}
+
+ {step === "device" && devicePhase === "starting" ? (
{t("auth.requestingCode")}
) : null}
- {phase === "awaiting" && flow ? (
+ {step === "device" && devicePhase === "awaiting" && flow ? (
) : null}
- {phase === "success" ? {t("accounts.added")}
: null}
-
- {phase === "error" ? (
- <>
- {error}
- {t("auth.continue")}
- >
+ {step === "token" && selected ? (
+
) : null}
+
+ {step === "success" ? {t("accounts.added")}
: null}
+
+ {error ? {error}
: null}
);
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 9b3eaa5..08b01ae 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -26,6 +26,12 @@ export const de: Record = {
"accounts.added": "Konto hinzugefügt. Aktualisieren…",
"accounts.remove": "{name} entfernen",
"accounts.removeConfirm": "{name} aus diesem Dashboard entfernen?",
+ "accounts.pickProvider": "Provider auswählen",
+ "accounts.viaDeviceFlow": "Mit Gerätecode anmelden",
+ "accounts.viaToken": "Persönlichen Access-Token verwenden",
+ "accounts.tokenLabel": "Persönlicher Access-Token",
+ "accounts.tokenRequired": "Token ist erforderlich",
+ "accounts.tokenHelp": "Erstellen Sie einen Token auf {provider} mit Lesezugriff für Repos, Issues und Benachrichtigungen.",
"common.authenticated": "Authentifiziert",
"common.authenticatedExternally": "Extern authentifiziert",
"common.export": "Exportieren",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index b90ff82..22a999c 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -24,6 +24,12 @@ export const en = {
"accounts.added": "Account added. Refreshing…",
"accounts.remove": "Remove {name}",
"accounts.removeConfirm": "Remove {name} from this dashboard?",
+ "accounts.pickProvider": "Pick a provider",
+ "accounts.viaDeviceFlow": "Sign in with device code",
+ "accounts.viaToken": "Use a personal access token",
+ "accounts.tokenLabel": "Personal access token",
+ "accounts.tokenRequired": "Token is required",
+ "accounts.tokenHelp": "Create a token on {provider} with read scopes for repos, issues and notifications.",
"common.authenticated": "Authenticated",
"common.authenticatedExternally": "Authenticated externally",
"common.export": "Export",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 22357ad..8d65738 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -26,6 +26,12 @@ export const es: Record = {
"accounts.added": "Cuenta añadida. Actualizando…",
"accounts.remove": "Eliminar {name}",
"accounts.removeConfirm": "¿Eliminar {name} de este panel?",
+ "accounts.pickProvider": "Elige un proveedor",
+ "accounts.viaDeviceFlow": "Inicia sesión con código de dispositivo",
+ "accounts.viaToken": "Usa un token de acceso personal",
+ "accounts.tokenLabel": "Token de acceso personal",
+ "accounts.tokenRequired": "El token es obligatorio",
+ "accounts.tokenHelp": "Crea un token en {provider} con permisos de lectura para repos, issues y notificaciones.",
"common.authenticated": "Autenticado",
"common.authenticatedExternally": "Autenticado externamente",
"common.export": "Exportar",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index f991f6d..1a4a948 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -26,6 +26,12 @@ export const fr: Record = {
"accounts.added": "Compte ajouté. Actualisation…",
"accounts.remove": "Supprimer {name}",
"accounts.removeConfirm": "Supprimer {name} de ce tableau de bord ?",
+ "accounts.pickProvider": "Choisir un fournisseur",
+ "accounts.viaDeviceFlow": "Connexion avec code d'appareil",
+ "accounts.viaToken": "Utiliser un token d'accès personnel",
+ "accounts.tokenLabel": "Token d'accès personnel",
+ "accounts.tokenRequired": "Le token est requis",
+ "accounts.tokenHelp": "Créez un token sur {provider} avec les portées de lecture pour dépôts, issues et notifications.",
"common.authenticated": "Authentifié",
"common.authenticatedExternally": "Authentifié en externe",
"common.export": "Exporter",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 24916ce..28dc505 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -26,6 +26,12 @@ export const it: Record = {
"accounts.added": "Account aggiunto. Aggiornamento…",
"accounts.remove": "Rimuovi {name}",
"accounts.removeConfirm": "Rimuovere {name} da questa dashboard?",
+ "accounts.pickProvider": "Scegli un provider",
+ "accounts.viaDeviceFlow": "Accedi con device code",
+ "accounts.viaToken": "Usa un personal access token",
+ "accounts.tokenLabel": "Personal access token",
+ "accounts.tokenRequired": "Il token è obbligatorio",
+ "accounts.tokenHelp": "Crea un token su {provider} con permessi di lettura per repo, issue e notifiche.",
"common.authenticated": "Autenticato",
"common.authenticatedExternally": "Autenticato esternamente",
"common.export": "Esporta",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index b3f3ae7..18ce4c7 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -26,6 +26,12 @@ export const zh: Record = {
"accounts.added": "账户已添加,正在刷新…",
"accounts.remove": "移除 {name}",
"accounts.removeConfirm": "从此面板移除 {name}?",
+ "accounts.pickProvider": "选择服务商",
+ "accounts.viaDeviceFlow": "使用设备代码登录",
+ "accounts.viaToken": "使用个人访问令牌",
+ "accounts.tokenLabel": "个人访问令牌",
+ "accounts.tokenRequired": "需要令牌",
+ "accounts.tokenHelp": "在 {provider} 上创建一个对仓库、issue 和通知有读取权限的令牌。",
"common.authenticated": "已认证",
"common.authenticatedExternally": "外部认证",
"common.export": "导出",
diff --git a/src/server.ts b/src/server.ts
index d1f2ea3..10db89b 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -28,13 +28,16 @@ import {
} from "./server/oauth";
import { getAuthMode } from "./server/authProvider";
import {
+ add as addAccount,
getActive as getActiveAccount,
+ getProviderConfig,
init as initAccountStore,
list as listAccountsStore,
+ listProviderConfigs,
remove as removeAccountStore,
setActive as setActiveAccount,
} from "./server/accountStore";
-import { getProviderForAccount } from "./server/providers/registry";
+import { getProvider, getProviderForAccount } from "./server/providers/registry";
import { send, sendJson, sendJsonCacheable, sendStaticFile } from "./server/http";
import {
getNotificationsCached,
@@ -912,6 +915,59 @@ async function handleAccountActivate(req: IncomingMessage, res: ServerResponse):
sendJson(res, 200, { ok: true, activeId: account.id });
}
+async function handleProviderConfigsList(res: ServerResponse): Promise {
+ await initAccountStore();
+ const configs = await listProviderConfigs();
+ const summaries = Object.values(configs).map((cfg) => ({
+ id: cfg.id,
+ kind: cfg.kind,
+ label: cfg.label,
+ webUrl: cfg.webUrl,
+ supportsDeviceFlow: Boolean(cfg.oauthDeviceCodeUrl) && cfg.kind === "github",
+ }));
+ sendJson(res, 200, { ok: true, configs: summaries });
+}
+
+async function handleAccountAddToken(req: IncomingMessage, res: ServerResponse): Promise {
+ if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "POST required" });
+ let parsed: { providerConfigId?: string; token?: string; label?: string };
+ try { parsed = (await readJsonBody(req)) as typeof parsed; }
+ catch { return sendJson(res, 400, { ok: false, error: "invalid JSON" }); }
+ const providerConfigId = (parsed.providerConfigId || "").trim();
+ const token = (parsed.token || "").trim();
+ if (!providerConfigId) return sendJson(res, 400, { ok: false, error: "missing providerConfigId" });
+ if (!token) return sendJson(res, 400, { ok: false, error: "missing token" });
+ await initAccountStore();
+ const config = await getProviderConfig(providerConfigId);
+ if (!config) return sendJson(res, 404, { ok: false, error: "unknown providerConfigId" });
+ let identity;
+ try {
+ const provider = await getProvider(providerConfigId);
+ identity = await provider.fetchIdentity(token);
+ } catch (error) {
+ return sendJson(res, 400, { ok: false, error: (error as Error).message });
+ }
+ if (!identity.login) return sendJson(res, 400, { ok: false, error: "provider did not return a login" });
+ const safeLogin = identity.login.replace(/[^a-zA-Z0-9_-]/g, "_");
+ const prefix = config.kind === "github" ? "gh" : "fj";
+ const webHost = new URL(config.webUrl).host;
+ const account = await addAccount({
+ id: `${prefix}_${safeLogin}_${providerConfigId}`,
+ providerKind: config.kind,
+ providerConfigId,
+ label: parsed.label?.trim() || `${identity.login} (${webHost})`,
+ login: identity.login,
+ accessToken: token,
+ scope: identity.scope ?? "",
+ obtainedAt: new Date().toISOString(),
+ source: "token",
+ });
+ invalidateDataCache();
+ invalidateNotificationsCache();
+ invalidateCIHealthCache();
+ sendJson(res, 200, { ok: true, accountId: account.id });
+}
+
async function handleAccountRemove(req: IncomingMessage, res: ServerResponse, u: URL): Promise {
if (req.method !== "DELETE") return sendJson(res, 405, { ok: false, error: "DELETE required" });
const id = (u.searchParams.get("id") || "").trim();
@@ -1030,6 +1086,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise
if (url.startsWith("/api/auth/poll")) return handleAuthPoll(req, res);
if (url.startsWith("/api/auth/logout")) return handleAuthLogout(req, res);
if (url.startsWith("/api/accounts/activate")) return handleAccountActivate(req, res);
+ if (url.startsWith("/api/accounts/add-token")) return handleAccountAddToken(req, res);
+ if (url.startsWith("/api/provider-configs")) return handleProviderConfigsList(res);
if (url.startsWith("/api/accounts")) {
if (req.method === "DELETE") {
return handleAccountRemove(req, res, new URL(url, "http://localhost"));
diff --git a/src/server/providers/forgejo.ts b/src/server/providers/forgejo.ts
new file mode 100644
index 0000000..f2bc6eb
--- /dev/null
+++ b/src/server/providers/forgejo.ts
@@ -0,0 +1,85 @@
+import type {
+ DeviceFlowPoll,
+ DeviceFlowStart,
+ Provider,
+ ProviderCapabilities,
+ ProviderConfig,
+ ProviderIdentity,
+} from "./types";
+
+const CAPABILITIES: ProviderCapabilities = {
+ graphql: false,
+ notifications: true,
+ projects: false,
+ ciWorkflows: false,
+ codeSearch: false,
+ dependents: false,
+ traffic: false,
+ stargazerHistory: false,
+};
+
+export class ForgejoProvider implements Provider {
+ readonly kind = "forgejo" as const;
+ readonly capabilities = CAPABILITIES;
+
+ constructor(readonly config: ProviderConfig) {}
+
+ async startDeviceFlow(): Promise {
+ throw new Error(
+ `Device flow is not enabled for ${this.config.id}. Add a personal access token instead.`,
+ );
+ }
+
+ async pollDeviceFlow(): Promise {
+ return { status: "error", error: "device flow not supported" };
+ }
+
+ async fetchIdentity(token: string): Promise {
+ const response = await fetch(`${this.config.baseUrl}/user`, {
+ headers: {
+ Accept: "application/json",
+ "User-Agent": this.config.userAgent,
+ Authorization: `token ${token}`,
+ },
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Identity lookup failed: ${text || `HTTP ${response.status}`}`);
+ }
+ const data = (await response.json()) as {
+ login?: string;
+ username?: string;
+ avatar_url?: string;
+ html_url?: string;
+ };
+ const login = data.login ?? data.username ?? "";
+ if (!login) throw new Error("Forgejo /user response missing login");
+ return {
+ login,
+ scope: null,
+ avatarUrl: data.avatar_url ?? null,
+ htmlUrl: data.html_url ?? null,
+ };
+ }
+
+ 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}/pulls/${parts.number}`;
+ }
+ }
+}
diff --git a/src/server/providers/registry.ts b/src/server/providers/registry.ts
index aef085c..c642bee 100644
--- a/src/server/providers/registry.ts
+++ b/src/server/providers/registry.ts
@@ -1,4 +1,5 @@
import { getProviderConfig } from "../accountStore";
+import { ForgejoProvider } from "./forgejo";
import { GitHubProvider } from "./github";
import type { Account, Provider, ProviderConfig } from "./types";
@@ -9,10 +10,7 @@ function build(config: ProviderConfig): Provider {
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})`);
+ return new ForgejoProvider(config);
}
}
diff --git a/src/styles/navigation.css b/src/styles/navigation.css
index 0298f3f..f95b2e9 100644
--- a/src/styles/navigation.css
+++ b/src/styles/navigation.css
@@ -202,6 +202,61 @@
background: var(--panel-2);
color: var(--text);
}
+ .add-account-providers {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .add-account-provider {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 1px solid var(--border-soft);
+ border-radius: 8px;
+ background: var(--panel-2);
+ color: var(--text);
+ cursor: pointer;
+ text-align: left;
+ font-size: 13px;
+ }
+ .add-account-provider:hover {
+ border-color: var(--accent);
+ }
+ .add-account-provider-label {
+ font-weight: 700;
+ }
+ .add-account-provider-meta {
+ color: var(--muted);
+ font-size: 11.5px;
+ }
+ .add-account-token {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+ .add-account-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ color: var(--muted);
+ font-size: 11.5px;
+ font-weight: 700;
+ }
+ .add-account-field input {
+ padding: 8px 10px;
+ border: 1px solid var(--border-soft);
+ border-radius: 7px;
+ background: var(--panel-2);
+ color: var(--text);
+ font-size: 13px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ }
+ .add-account-field input:focus-visible {
+ outline: none;
+ box-shadow: var(--ring);
+ }
.preferences-btn.active {
background: var(--panel-2);
border-color: var(--button-hover-border);
From 8557259bb8d57dea81f1d5dffc07110c67facab5 Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 09:31:57 +0200
Subject: [PATCH 6/9] feat(forgejo): route domain ops through the provider
interface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move the GitHub-specific fetchers (owners, repos, issues, pull
requests, notifications, mark-read mutations) out of dashboardData
and notifications and onto GitHubProvider. ForgejoProvider implements
the same operations against /api/v1 endpoints, mapping Gitea-style
payloads into GhRepo/GhIssue/GhPullRequest/GhNotification.
The core now resolves the active account, asks the registry for its
provider, and calls provider.listOwners/listRepos/etc with no
branches on providerKind. Adding a new backend means writing a
Provider implementation and a registry entry — no edits to
dashboardData or notifications.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/server/dashboardData.ts | 331 ++------------------
src/server/notifications.ts | 254 +++------------
src/server/providers/forgejo.ts | 58 ++++
src/server/providers/forgejoData.ts | 406 ++++++++++++++++++++++++
src/server/providers/github.ts | 463 ++++++++++++++++++++++++++++
src/server/providers/types.ts | 64 ++++
6 files changed, 1062 insertions(+), 514 deletions(-)
create mode 100644 src/server/providers/forgejoData.ts
diff --git a/src/server/dashboardData.ts b/src/server/dashboardData.ts
index f397a59..ab05462 100644
--- a/src/server/dashboardData.ts
+++ b/src/server/dashboardData.ts
@@ -1,6 +1,9 @@
-import type { GhIssue, GhLabel, GhPullRequest, GhRepo, GhUser, ReviewDecision } from "../types/github";
+import type { GhIssue, GhPullRequest, GhRepo } from "../types/github";
+import { getActive as getActiveAccount } from "./accountStore";
import { recordDailyDigest } from "./digests";
-import { AuthRequiredError, gql, restApi } from "./githubClient";
+import { AuthRequiredError } from "./githubClient";
+import { getProviderForAccount } from "./providers/registry";
+import type { Account, OwnersOutcome, Provider } from "./providers/types";
import { attachHistory, recordSnapshots } from "./snapshots";
export type ReposResult =
@@ -15,303 +18,6 @@ export type PullRequestsResult =
| { ok: true; pullRequests: GhPullRequest[]; owners: string[]; fetchedAt: string }
| { ok: false; error: string; needsAuth?: true };
-type OwnersResult =
- | { ok: true; owners: string[] }
- | { ok: false; error: string; needsAuth?: true };
-
-const ISSUE_SEARCH_QUERY = `
-query($q: String!, $cursor: String) {
- search(query: $q, type: ISSUE, first: 100, after: $cursor) {
- issueCount
- pageInfo { endCursor hasNextPage }
- nodes {
- __typename
- ... on Issue {
- number title url createdAt updatedAt
- author { login url }
- repository { name nameWithOwner }
- labels(first: 20) { nodes { name color description } }
- comments { totalCount }
- assignees(first: 10) { nodes { login url avatarUrl } }
- }
- }
- }
-}`;
-
-const REPO_LIST_QUERY = `
-query($owner: String!, $cursor: String) {
- repositoryOwner(login: $owner) {
- repositories(first: 100, after: $cursor, ownerAffiliations: [OWNER]) {
- pageInfo { endCursor hasNextPage }
- nodes {
- nameWithOwner name
- owner { login avatarUrl }
- description stargazerCount forkCount
- primaryLanguage { name }
- updatedAt pushedAt
- visibility
- isPrivate isArchived isFork url
- }
- }
- }
-}`;
-
-const PR_SEARCH_QUERY = `
-query($q: String!, $cursor: String) {
- search(query: $q, type: ISSUE, first: 100, after: $cursor) {
- issueCount
- pageInfo { endCursor hasNextPage }
- nodes {
- __typename
- ... on PullRequest {
- number title url createdAt updatedAt
- isDraft reviewDecision
- author { login url }
- repository { name nameWithOwner }
- labels(first: 20) { nodes { name color description } }
- comments { totalCount }
- reviews { totalCount }
- assignees(first: 10) { nodes { login url avatarUrl } }
- additions deletions changedFiles
- baseRefName headRefName
- }
- }
- }
-}`;
-
-const ISSUE_PAGE_LIMIT = 10;
-const PR_PAGE_LIMIT = 10;
-const REPO_PAGE_LIMIT = 10;
-
-interface IssueSearchNode {
- __typename: string;
- number: number;
- title: string;
- url: string;
- createdAt: string;
- updatedAt: string;
- author: { login: string; url: string } | null;
- repository: { name: string; nameWithOwner: string };
- labels: { nodes: GhLabel[] };
- comments: { totalCount: number };
- assignees: { nodes: GhUser[] };
-}
-
-interface IssueSearchResponse {
- search: {
- pageInfo: { endCursor: string | null; hasNextPage: boolean };
- nodes: (IssueSearchNode | { __typename: string })[];
- };
-}
-
-interface PullRequestSearchNode {
- __typename: string;
- number: number;
- title: string;
- url: string;
- createdAt: string;
- updatedAt: string;
- isDraft: boolean;
- reviewDecision: ReviewDecision;
- author: { login: string; url: string } | null;
- repository: { name: string; nameWithOwner: string };
- labels: { nodes: GhLabel[] };
- comments: { totalCount: number };
- reviews: { totalCount: number };
- assignees: { nodes: GhUser[] };
- additions: number;
- deletions: number;
- changedFiles: number;
- baseRefName: string;
- headRefName: string;
-}
-
-interface PullRequestSearchResponse {
- search: {
- pageInfo: { endCursor: string | null; hasNextPage: boolean };
- nodes: (PullRequestSearchNode | { __typename: string })[];
- };
-}
-
-interface RepoNode {
- nameWithOwner: string;
- name: string;
- owner: { login: string; avatarUrl?: string };
- description: string | null;
- stargazerCount: number;
- forkCount: number;
- primaryLanguage: { name: string } | null;
- updatedAt: string;
- pushedAt: string;
- visibility: string;
- isPrivate: boolean;
- isArchived: boolean;
- isFork: boolean;
- url: string;
-}
-
-interface RepoListResponse {
- repositoryOwner: {
- repositories: {
- pageInfo: { endCursor: string | null; hasNextPage: boolean };
- nodes: RepoNode[];
- };
- } | null;
-}
-
-async function fetchOwners(): Promise {
- try {
- const userResult = await restApi<{ login: string }>("/user");
- if (!userResult.ok) {
- if (userResult.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
- return { ok: false, error: `/user: ${userResult.error}` };
- }
- const orgsResult = await restApi<{ login: string }[]>("/user/orgs");
- if (!orgsResult.ok) {
- if (orgsResult.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
- return { ok: false, error: `/user/orgs: ${orgsResult.error}` };
- }
- const orgs = orgsResult.data.map((entry) => entry.login).filter(Boolean);
- const owners = Array.from(new Set([userResult.data.login, ...orgs].filter(Boolean)));
- return { ok: true, owners };
- } catch (error: unknown) {
- if (error instanceof AuthRequiredError) return { ok: false, error: "authentication required", needsAuth: true };
- return { ok: false, error: (error as Error).message || String(error) };
- }
-}
-
-function buildIssueQuery(owners: string[]): string {
- const ownerScope = owners.map((owner) => `user:${owner}`).join(" ");
- return `is:issue is:open ${ownerScope}`.trim();
-}
-
-function buildPullRequestQuery(owners: string[]): string {
- const ownerScope = owners.map((owner) => `user:${owner}`).join(" ");
- return `is:pr is:open ${ownerScope}`.trim();
-}
-
-async function fetchIssues(owners: string[]): Promise {
- if (!owners.length) return [];
- const q = buildIssueQuery(owners);
- const collected: GhIssue[] = [];
- let cursor: string | null = null;
-
- for (let page = 0; page < ISSUE_PAGE_LIMIT; page++) {
- const data: IssueSearchResponse = await gql(ISSUE_SEARCH_QUERY, { q, cursor });
- for (const raw of data.search.nodes) {
- if (raw.__typename !== "Issue") continue;
- const node = raw as IssueSearchNode;
- collected.push({
- repository: node.repository,
- title: node.title,
- url: node.url,
- number: node.number,
- createdAt: node.createdAt,
- updatedAt: node.updatedAt,
- author: node.author ?? undefined,
- labels: node.labels?.nodes ?? [],
- commentsCount: node.comments?.totalCount ?? 0,
- assignees: node.assignees?.nodes ?? [],
- });
- }
- if (!data.search.pageInfo.hasNextPage) break;
- cursor = data.search.pageInfo.endCursor;
- if (!cursor) break;
- }
- return collected;
-}
-
-async function fetchPullRequests(owners: string[]): Promise {
- if (!owners.length) return [];
- const q = buildPullRequestQuery(owners);
- const collected: GhPullRequest[] = [];
- let cursor: string | null = null;
-
- for (let page = 0; page < PR_PAGE_LIMIT; page++) {
- const data: PullRequestSearchResponse = await gql(PR_SEARCH_QUERY, { q, cursor });
- for (const raw of data.search.nodes) {
- if (raw.__typename !== "PullRequest") continue;
- const node = raw as PullRequestSearchNode;
- collected.push({
- repository: node.repository,
- title: node.title,
- url: node.url,
- number: node.number,
- createdAt: node.createdAt,
- updatedAt: node.updatedAt,
- author: node.author ?? undefined,
- labels: node.labels?.nodes ?? [],
- commentsCount: node.comments?.totalCount ?? 0,
- assignees: node.assignees?.nodes ?? [],
- isDraft: node.isDraft,
- reviewDecision: node.reviewDecision,
- reviewsCount: node.reviews?.totalCount ?? 0,
- additions: node.additions,
- deletions: node.deletions,
- changedFiles: node.changedFiles,
- baseRefName: node.baseRefName,
- headRefName: node.headRefName,
- });
- }
- if (!data.search.pageInfo.hasNextPage) break;
- cursor = data.search.pageInfo.endCursor;
- if (!cursor) break;
- }
- return collected;
-}
-
-async function fetchReposForOwner(owner: string): Promise {
- const collected: GhRepo[] = [];
- let cursor: string | null = null;
-
- for (let page = 0; page < REPO_PAGE_LIMIT; page++) {
- let data: RepoListResponse;
- try {
- data = await gql(REPO_LIST_QUERY, { owner, cursor });
- } catch {
- return collected;
- }
- const owned = data.repositoryOwner?.repositories;
- if (!owned) return collected;
- for (const node of owned.nodes) {
- collected.push({
- nameWithOwner: node.nameWithOwner,
- name: node.name,
- owner: node.owner,
- description: node.description,
- stargazerCount: node.stargazerCount,
- forkCount: node.forkCount,
- primaryLanguage: node.primaryLanguage,
- updatedAt: node.updatedAt,
- pushedAt: node.pushedAt,
- visibility: node.visibility?.toLowerCase() ?? "",
- isPrivate: node.isPrivate,
- isArchived: node.isArchived,
- isFork: node.isFork,
- url: node.url,
- });
- }
- if (!owned.pageInfo.hasNextPage) break;
- cursor = owned.pageInfo.endCursor;
- if (!cursor) break;
- }
- return collected;
-}
-
-async function fetchAllRepos(owners: string[]): Promise {
- const lists = await Promise.all(owners.map(fetchReposForOwner));
- const seen = new Set();
- const repos: GhRepo[] = [];
- for (const list of lists) {
- for (const repo of list) {
- if (seen.has(repo.nameWithOwner)) continue;
- seen.add(repo.nameWithOwner);
- repos.push(repo);
- }
- }
- return repos;
-}
-
const TTL_MS = 5 * 60 * 1000;
interface Memoized {
@@ -347,8 +53,6 @@ function memoize(ttlMs: number, fetcher: () => Promis
};
}
-const ownersStore = memoize(TTL_MS, fetchOwners);
-
function authFail(): { ok: false; error: string; needsAuth: true } {
return { ok: false, error: "authentication required", needsAuth: true };
}
@@ -357,11 +61,26 @@ function genericFail(error: unknown): { ok: false; error: string } {
return { ok: false, error: (error as Error).message || String(error) };
}
+async function resolveActive(): Promise<{ account: Account; provider: Provider } | null> {
+ const account = await getActiveAccount();
+ if (!account) return null;
+ const provider = await getProviderForAccount(account);
+ return { account, provider };
+}
+
+const ownersStore = memoize(TTL_MS, async (): Promise => {
+ const active = await resolveActive();
+ if (!active) return authFail();
+ return active.provider.listOwners(active.account);
+});
+
const reposStore = memoize(TTL_MS, async (): Promise => {
const ownersResult = await ownersStore.get(false);
if (!ownersResult.ok) return ownersResult.needsAuth ? authFail() : { ok: false, error: ownersResult.error };
try {
- const repos = await fetchAllRepos(ownersResult.owners);
+ const active = await resolveActive();
+ if (!active) return authFail();
+ const repos = await active.provider.listRepos(active.account, ownersResult.owners);
try {
await recordSnapshots(repos);
await attachHistory(repos);
@@ -381,7 +100,9 @@ const issuesStore = memoize(TTL_MS, async (): Promise(TTL_MS, async (): Promise<
const ownersResult = await ownersStore.get(false);
if (!ownersResult.ok) return ownersResult.needsAuth ? authFail() : { ok: false, error: ownersResult.error };
try {
- const pullRequests = await fetchPullRequests(ownersResult.owners);
+ const active = await resolveActive();
+ if (!active) return authFail();
+ const pullRequests = await active.provider.listPullRequests(active.account, ownersResult.owners);
return { ok: true, pullRequests, owners: ownersResult.owners, fetchedAt: new Date().toISOString() };
} catch (error: unknown) {
if (error instanceof AuthRequiredError) return authFail();
diff --git a/src/server/notifications.ts b/src/server/notifications.ts
index 4e96f51..e33e851 100644
--- a/src/server/notifications.ts
+++ b/src/server/notifications.ts
@@ -1,100 +1,9 @@
-import type { GhNotification, GhNotificationReason, NotificationsData } from "../types/github";
-import { AuthRequiredError, getToken } from "./githubClient";
+import type { GhNotification, NotificationsData } from "../types/github";
+import { getActive as getActiveAccount } from "./accountStore";
+import { getProviderForAccount } from "./providers/registry";
+import type { Account, Provider } from "./providers/types";
-const API_ROOT = "https://api.github.com";
-const USER_AGENT = "gh-issues-dashboard";
-const PAGE_LIMIT = 5;
-const PER_PAGE = 50;
const TTL_MS = 60 * 1000;
-const DEFAULT_POLL_INTERVAL = 60;
-
-interface RawNotification {
- id: string;
- unread: boolean;
- reason: string;
- updated_at: string;
- last_read_at: string | null;
- subject: {
- title: string;
- url: string | null;
- latest_comment_url: string | null;
- type: string;
- };
- repository: {
- name: string;
- full_name: string;
- private: boolean;
- html_url: string;
- };
-}
-
-function authHeaders(token: string, extra?: Record): Record {
- return {
- Accept: "application/vnd.github+json",
- "User-Agent": USER_AGENT,
- Authorization: `Bearer ${token}`,
- "X-GitHub-Api-Version": "2022-11-28",
- ...(extra ?? {}),
- };
-}
-
-function parseNextLink(header: string | null): string | null {
- if (!header) return null;
- for (const part of header.split(",")) {
- const match = /<([^>]+)>;\s*rel="next"/.exec(part.trim());
- if (match) return match[1];
- }
- return null;
-}
-
-const SUBJECT_NUMBER_PATTERN = /\/(?:issues|pulls)\/(\d+)$/;
-
-function deriveItemNumber(subjectUrl: string | null): number | null {
- if (!subjectUrl) return null;
- const match = SUBJECT_NUMBER_PATTERN.exec(subjectUrl);
- return match ? Number(match[1]) : null;
-}
-
-function deriveItemHtmlUrl(repoHtmlUrl: string, subjectUrl: string | null, subjectType: string, itemNumber: number | null): string | null {
- if (!itemNumber || !subjectUrl) return null;
- if (subjectType === "PullRequest") return `${repoHtmlUrl}/pull/${itemNumber}`;
- if (subjectType === "Issue") return `${repoHtmlUrl}/issues/${itemNumber}`;
- return null;
-}
-
-function normalizeReason(reason: string): GhNotificationReason {
- return reason as GhNotificationReason;
-}
-
-function normalize(raw: RawNotification): GhNotification {
- const itemNumber = deriveItemNumber(raw.subject?.url ?? null);
- return {
- id: raw.id,
- unread: Boolean(raw.unread),
- reason: normalizeReason(raw.reason),
- updatedAt: raw.updated_at,
- lastReadAt: raw.last_read_at,
- subject: {
- title: raw.subject?.title ?? "",
- url: raw.subject?.url ?? null,
- latestCommentUrl: raw.subject?.latest_comment_url ?? null,
- type: raw.subject?.type ?? "",
- },
- repository: {
- name: raw.repository?.name ?? "",
- nameWithOwner: raw.repository?.full_name ?? "",
- private: Boolean(raw.repository?.private),
- htmlUrl: raw.repository?.html_url ?? "",
- },
- itemNumber,
- itemHtmlUrl: deriveItemHtmlUrl(
- raw.repository?.html_url ?? "",
- raw.subject?.url ?? null,
- raw.subject?.type ?? "",
- itemNumber,
- ),
- };
-}
interface FetchState {
notifications: GhNotification[];
@@ -110,63 +19,6 @@ export type NotificationsResult =
| { ok: true; data: NotificationsData }
| { ok: false; error: string; needsAuth?: true };
-async function fetchPage(token: string, url: string, ifModifiedSince: string | null): Promise<{ status: number; raw: RawNotification[]; nextUrl: string | null; lastModified: string | null; pollInterval: number; }> {
- const headers = authHeaders(token, ifModifiedSince ? { "If-Modified-Since": ifModifiedSince } : undefined);
- const response = await fetch(url, { headers });
- if (response.status === 401) throw new AuthRequiredError();
- const lastModified = response.headers.get("last-modified");
- const intervalHeader = response.headers.get("x-poll-interval");
- const pollInterval = intervalHeader ? Math.max(1, Number(intervalHeader)) : DEFAULT_POLL_INTERVAL;
- if (response.status === 304) {
- return { status: 304, raw: [], nextUrl: null, lastModified, pollInterval };
- }
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || `HTTP ${response.status}`);
- }
- const raw = (await response.json()) as RawNotification[];
- return {
- status: response.status,
- raw,
- nextUrl: parseNextLink(response.headers.get("link")),
- lastModified,
- pollInterval,
- };
-}
-
-async function fetchAllNotifications(token: string, ifModifiedSince: string | null): Promise<{ refreshed: boolean; notifications: GhNotification[]; lastModified: string | null; pollInterval: number; }> {
- const initial = `${API_ROOT}/notifications?all=true&participating=false&per_page=${PER_PAGE}`;
- const collected: GhNotification[] = [];
- let url: string | null = initial;
- let firstLastModified: string | null = null;
- let firstPollInterval = DEFAULT_POLL_INTERVAL;
- let firstStatus = 0;
- let pages = 0;
-
- while (url && pages < PAGE_LIMIT) {
- const ims = pages === 0 ? ifModifiedSince : null;
- const page = await fetchPage(token, url, ims);
- if (pages === 0) {
- firstLastModified = page.lastModified;
- firstPollInterval = page.pollInterval;
- firstStatus = page.status;
- if (page.status === 304) {
- return { refreshed: false, notifications: [], lastModified: firstLastModified, pollInterval: firstPollInterval };
- }
- }
- for (const raw of page.raw) collected.push(normalize(raw));
- url = page.nextUrl;
- pages += 1;
- }
-
- return {
- refreshed: firstStatus !== 304,
- notifications: collected,
- lastModified: firstLastModified,
- pollInterval: firstPollInterval,
- };
-}
-
function toResponse(state: FetchState): NotificationsData {
return {
ok: true,
@@ -176,40 +28,38 @@ function toResponse(state: FetchState): NotificationsData {
};
}
+async function resolveActive(): Promise<{ account: Account; provider: Provider } | null> {
+ const account = await getActiveAccount();
+ if (!account) return null;
+ const provider = await getProviderForAccount(account);
+ return { account, provider };
+}
+
async function loadFresh(): Promise {
- let token: string;
- try {
- token = await getToken();
- } catch (error) {
- if (error instanceof AuthRequiredError) return { ok: false, error: "authentication required", needsAuth: true };
- return { ok: false, error: (error as Error).message || String(error) };
- }
- try {
- const ifModifiedSince = cache?.state.lastModified ?? null;
- const result = await fetchAllNotifications(token, ifModifiedSince);
- const fetchedAt = new Date().toISOString();
- if (!result.refreshed && cache) {
- const state: FetchState = {
- notifications: cache.state.notifications,
- lastModified: result.lastModified ?? cache.state.lastModified,
- pollInterval: result.pollInterval,
- fetchedAt,
- };
- cache = { state, expiresAt: Date.now() + TTL_MS };
- return { ok: true, data: toResponse(state) };
- }
+ const active = await resolveActive();
+ if (!active) return { ok: false, error: "authentication required", needsAuth: true };
+ const ifModifiedSince = cache?.state.lastModified ?? null;
+ const result = await active.provider.fetchNotifications(active.account, ifModifiedSince);
+ if (!result.ok) return { ok: false, error: result.error, needsAuth: result.needsAuth };
+ const fetchedAt = new Date().toISOString();
+ if (!result.refreshed && cache) {
const state: FetchState = {
- notifications: result.notifications,
- lastModified: result.lastModified,
+ notifications: cache.state.notifications,
+ lastModified: result.lastModified ?? cache.state.lastModified,
pollInterval: result.pollInterval,
fetchedAt,
};
cache = { state, expiresAt: Date.now() + TTL_MS };
return { ok: true, data: toResponse(state) };
- } catch (error) {
- if (error instanceof AuthRequiredError) return { ok: false, error: "authentication required", needsAuth: true };
- return { ok: false, error: (error as Error).message || String(error) };
}
+ const state: FetchState = {
+ notifications: result.notifications,
+ lastModified: result.lastModified,
+ pollInterval: result.pollInterval,
+ fetchedAt,
+ };
+ cache = { state, expiresAt: Date.now() + TTL_MS };
+ return { ok: true, data: toResponse(state) };
}
export async function getNotificationsCached(forceFresh: boolean): Promise {
@@ -230,31 +80,6 @@ export interface ReadResult {
needsAuth?: true;
}
-async function mutate(method: "PATCH" | "PUT", path: string, body?: unknown): Promise {
- let token: string;
- try {
- token = await getToken();
- } catch (error) {
- if (error instanceof AuthRequiredError) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
- return { ok: false, status: 500, error: (error as Error).message || String(error) };
- }
- try {
- const response = await fetch(`${API_ROOT}${path}`, {
- method,
- headers: authHeaders(token, body ? { "Content-Type": "application/json" } : undefined),
- body: body ? JSON.stringify(body) : undefined,
- });
- if (response.status === 401) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
- if (!response.ok && response.status !== 205) {
- const text = await response.text();
- return { ok: false, status: response.status, error: text || `HTTP ${response.status}` };
- }
- return { ok: true, status: response.status };
- } catch (error) {
- return { ok: false, status: 500, error: (error as Error).message || String(error) };
- }
-}
-
function patchCacheThreadRead(threadId: string): void {
if (!cache) return;
const next = cache.state.notifications.map((entry) =>
@@ -274,17 +99,26 @@ function patchCacheAllRead(repoFullName: string | null, lastReadAt: string): voi
}
export async function markThreadRead(threadId: string): Promise {
- const result = await mutate("PATCH", `/notifications/threads/${encodeURIComponent(threadId)}`);
- if (result.ok) patchCacheThreadRead(threadId);
- return result;
+ const active = await resolveActive();
+ if (!active) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
+ const result = await active.provider.markNotificationRead(active.account, threadId);
+ if (result.ok) {
+ patchCacheThreadRead(threadId);
+ return { ok: true, status: result.status };
+ }
+ return { ok: false, status: result.status, error: result.error, needsAuth: result.needsAuth };
}
export async function markAllRead(options: { repo?: string | null; lastReadAt?: string | null } = {}): Promise {
const lastReadAt = options.lastReadAt ?? new Date().toISOString();
- const path = options.repo ? `/repos/${options.repo}/notifications` : "/notifications";
- const result = await mutate("PUT", path, { last_read_at: lastReadAt, read: true });
- if (result.ok) patchCacheAllRead(options.repo ?? null, lastReadAt);
- return result;
+ const active = await resolveActive();
+ if (!active) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
+ const result = await active.provider.markAllNotificationsRead(active.account, { repo: options.repo, lastReadAt });
+ if (result.ok) {
+ patchCacheAllRead(options.repo ?? null, lastReadAt);
+ return { ok: true, status: result.status };
+ }
+ return { ok: false, status: result.status, error: result.error, needsAuth: result.needsAuth };
}
export function invalidateNotificationsCache(): void {
diff --git a/src/server/providers/forgejo.ts b/src/server/providers/forgejo.ts
index f2bc6eb..bf68c4e 100644
--- a/src/server/providers/forgejo.ts
+++ b/src/server/providers/forgejo.ts
@@ -1,6 +1,24 @@
import type {
+ GhIssue,
+ GhPullRequest,
+ GhRepo,
+} from "../../types/github";
+import {
+ fetchForgejoIssues,
+ fetchForgejoNotifications,
+ fetchForgejoOwners,
+ fetchForgejoPullRequests,
+ fetchForgejoRepos,
+ markForgejoAllRead,
+ markForgejoThreadRead,
+} from "./forgejoData";
+import type {
+ Account,
DeviceFlowPoll,
DeviceFlowStart,
+ NotificationMutationOutcome,
+ NotificationsFetchOutcome,
+ OwnersOutcome,
Provider,
ProviderCapabilities,
ProviderConfig,
@@ -62,6 +80,46 @@ export class ForgejoProvider implements Provider {
};
}
+ async listOwners(account: Account): Promise {
+ return fetchForgejoOwners(account);
+ }
+
+ async listRepos(account: Account, owners: string[]): Promise {
+ return fetchForgejoRepos(account, owners);
+ }
+
+ async listIssues(account: Account, owners: string[]): Promise {
+ return fetchForgejoIssues(account, owners);
+ }
+
+ async listPullRequests(account: Account, owners: string[]): Promise {
+ return fetchForgejoPullRequests(account, owners);
+ }
+
+ async fetchNotifications(account: Account, ifModifiedSince: string | null): Promise {
+ const result = await fetchForgejoNotifications(account, ifModifiedSince);
+ if ("error" in result) return { ok: false, error: result.error, needsAuth: result.needsAuth };
+ return {
+ ok: true,
+ refreshed: result.refreshed,
+ notifications: result.notifications,
+ lastModified: result.lastModified,
+ pollInterval: result.pollInterval,
+ };
+ }
+
+ async markNotificationRead(account: Account, threadId: string): Promise {
+ const result = await markForgejoThreadRead(account, threadId);
+ if (result.ok) return { ok: true, status: result.status };
+ return { ok: false, status: result.status, error: result.error, needsAuth: result.needsAuth };
+ }
+
+ async markAllNotificationsRead(account: Account, options: { repo?: string | null; lastReadAt?: string | null }): Promise {
+ const result = await markForgejoAllRead(account, options);
+ if (result.ok) return { ok: true, status: result.status };
+ return { ok: false, status: result.status, error: result.error, needsAuth: result.needsAuth };
+ }
+
avatarUrl(login: string, size = 64): string {
return `${this.config.webUrl}/${encodeURIComponent(login)}.png?size=${size}`;
}
diff --git a/src/server/providers/forgejoData.ts b/src/server/providers/forgejoData.ts
new file mode 100644
index 0000000..2bdb55d
--- /dev/null
+++ b/src/server/providers/forgejoData.ts
@@ -0,0 +1,406 @@
+import type {
+ GhIssue,
+ GhNotification,
+ GhNotificationReason,
+ GhPullRequest,
+ GhRepo,
+ ReviewDecision,
+} from "../../types/github";
+import { getProviderConfig } from "../accountStore";
+import type { Account, ProviderConfig } from "./types";
+
+interface ForgejoUser {
+ id?: number;
+ login?: string;
+ username?: string;
+ full_name?: string;
+ avatar_url?: string;
+ html_url?: string;
+}
+
+interface ForgejoOrg {
+ id?: number;
+ username?: string;
+ name?: string;
+ full_name?: string;
+ avatar_url?: string;
+}
+
+interface ForgejoRepo {
+ id: number;
+ name: string;
+ full_name: string;
+ description: string | null;
+ html_url: string;
+ owner: ForgejoUser;
+ stars_count?: number;
+ stargazers_count?: number;
+ forks_count: number;
+ language?: string | null;
+ updated_at: string;
+ pushed_at?: string;
+ private?: boolean;
+ archived?: boolean;
+ fork?: boolean;
+ internal?: boolean;
+}
+
+interface ForgejoLabel {
+ id: number;
+ name: string;
+ color?: string;
+ description?: string;
+}
+
+interface ForgejoIssueLike {
+ id: number;
+ number: number;
+ title: string;
+ html_url: string;
+ state: string;
+ created_at: string;
+ updated_at: string;
+ user: ForgejoUser | null;
+ comments: number;
+ labels?: ForgejoLabel[];
+ assignees?: ForgejoUser[] | null;
+ repository?: { name?: string; full_name?: string; html_url?: string };
+ pull_request?: { merged?: boolean; html_url?: string; draft?: boolean } | null;
+}
+
+interface ForgejoNotification {
+ id: number | string;
+ unread: boolean;
+ pinned?: boolean;
+ updated_at: string;
+ url?: string;
+ subject?: {
+ title?: string;
+ url?: string | null;
+ latest_comment_url?: string | null;
+ type?: string;
+ state?: string;
+ };
+ repository?: {
+ name?: string;
+ full_name?: string;
+ html_url?: string;
+ private?: boolean;
+ };
+}
+
+type RestResult =
+ | { ok: true; data: T; status: number }
+ | { ok: false; status: number; error: string };
+
+function authHeaders(account: Account, extra?: Record): Record {
+ const config = providerConfigOf(account);
+ return {
+ Accept: "application/json",
+ "User-Agent": config.userAgent,
+ Authorization: `token ${account.accessToken}`,
+ ...(extra ?? {}),
+ };
+}
+
+const configCache = new Map();
+
+async function resolveConfig(account: Account): Promise {
+ const cached = configCache.get(account.providerConfigId);
+ if (cached) return cached;
+ const cfg = await getProviderConfig(account.providerConfigId);
+ if (!cfg) throw new Error(`Unknown provider config: ${account.providerConfigId}`);
+ configCache.set(account.providerConfigId, cfg);
+ return cfg;
+}
+
+function providerConfigOf(account: Account): ProviderConfig {
+ const cached = configCache.get(account.providerConfigId);
+ if (!cached) throw new Error(`Provider config for ${account.providerConfigId} not loaded`);
+ return cached;
+}
+
+function parseNextLink(header: string | null): string | null {
+ if (!header) return null;
+ for (const part of header.split(",")) {
+ const match = /<([^>]+)>;\s*rel="next"/.exec(part.trim());
+ if (match) return match[1];
+ }
+ return null;
+}
+
+async function rest(account: Account, path: string, init?: RequestInit): Promise> {
+ await resolveConfig(account);
+ const config = providerConfigOf(account);
+ const url = path.startsWith("http") ? path : `${config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
+ const response = await fetch(url, {
+ ...init,
+ headers: { ...authHeaders(account), ...((init?.headers as Record) ?? {}) },
+ });
+ const text = await response.text();
+ if (!response.ok) {
+ return { ok: false, status: response.status, error: text || `HTTP ${response.status}` };
+ }
+ if (!text) return { ok: true, status: response.status, data: null as T };
+ try {
+ return { ok: true, status: response.status, data: JSON.parse(text) as T };
+ } catch {
+ return { ok: false, status: response.status, error: "invalid JSON" };
+ }
+}
+
+async function restPaginate(account: Account, path: string, perPage = 50, maxPages = 10): Promise> {
+ await resolveConfig(account);
+ const config = providerConfigOf(account);
+ const separator = path.includes("?") ? "&" : "?";
+ const initialUrl = path.startsWith("http")
+ ? path
+ : `${config.baseUrl}${path.startsWith("/") ? path : `/${path}`}${separator}page=1&limit=${perPage}`;
+ const collected: T[] = [];
+ let url: string | null = initialUrl;
+ let pages = 0;
+ while (url && pages < maxPages) {
+ const response = await fetch(url, { headers: authHeaders(account) });
+ const text = await response.text();
+ if (!response.ok) return { ok: false, status: response.status, error: text || `HTTP ${response.status}` };
+ let page: unknown;
+ try { page = JSON.parse(text); }
+ catch { return { ok: false, status: response.status, error: "invalid JSON" }; }
+ if (Array.isArray(page)) {
+ for (const item of page) collected.push(item as T);
+ } else {
+ collected.push(page as T);
+ }
+ const link = response.headers.get("link");
+ url = parseNextLink(link);
+ pages += 1;
+ }
+ return { ok: true, status: 200, data: collected };
+}
+
+function normalizeRepo(raw: ForgejoRepo): GhRepo {
+ return {
+ nameWithOwner: raw.full_name,
+ name: raw.name,
+ owner: { login: raw.owner.login ?? raw.owner.username ?? "", avatarUrl: raw.owner.avatar_url },
+ description: raw.description,
+ stargazerCount: raw.stars_count ?? raw.stargazers_count ?? 0,
+ forkCount: raw.forks_count ?? 0,
+ primaryLanguage: raw.language ? { name: raw.language } : null,
+ updatedAt: raw.updated_at,
+ pushedAt: raw.pushed_at ?? raw.updated_at,
+ visibility: raw.private ? "private" : raw.internal ? "internal" : "public",
+ isPrivate: Boolean(raw.private),
+ isArchived: Boolean(raw.archived),
+ isFork: Boolean(raw.fork),
+ url: raw.html_url,
+ };
+}
+
+function repositoryFromIssueUrl(html_url: string, fallbackFull?: string): { name: string; nameWithOwner: string } {
+ // Forgejo issue html_url format: {webUrl}/{owner}/{repo}/issues/{n}
+ try {
+ const u = new URL(html_url);
+ const parts = u.pathname.split("/").filter(Boolean);
+ const owner = parts[0];
+ const repo = parts[1];
+ if (owner && repo) return { name: repo, nameWithOwner: `${owner}/${repo}` };
+ } catch {
+ // ignore
+ }
+ if (fallbackFull) {
+ const [, repo] = fallbackFull.split("/");
+ return { name: repo ?? fallbackFull, nameWithOwner: fallbackFull };
+ }
+ return { name: "", nameWithOwner: "" };
+}
+
+function normalizeIssue(raw: ForgejoIssueLike): GhIssue {
+ const repository = raw.repository?.full_name
+ ? { name: raw.repository.name ?? raw.repository.full_name.split("/")[1] ?? "", nameWithOwner: raw.repository.full_name }
+ : repositoryFromIssueUrl(raw.html_url);
+ return {
+ repository,
+ title: raw.title,
+ url: raw.html_url,
+ number: raw.number,
+ createdAt: raw.created_at,
+ updatedAt: raw.updated_at,
+ author: raw.user ? { login: raw.user.login ?? raw.user.username ?? "", avatarUrl: raw.user.avatar_url, url: raw.user.html_url } : undefined,
+ labels: (raw.labels ?? []).map((label) => ({ name: label.name, color: label.color, description: label.description })),
+ commentsCount: raw.comments ?? 0,
+ assignees: (raw.assignees ?? []).map((user) => ({ login: user.login ?? user.username ?? "", avatarUrl: user.avatar_url, url: user.html_url })),
+ };
+}
+
+function normalizePullRequest(raw: ForgejoIssueLike): GhPullRequest {
+ const base = normalizeIssue(raw);
+ const reviewDecision: ReviewDecision = "REVIEW_REQUIRED";
+ return {
+ ...base,
+ isDraft: Boolean(raw.pull_request?.draft),
+ reviewDecision,
+ reviewsCount: 0,
+ additions: 0,
+ deletions: 0,
+ changedFiles: 0,
+ baseRefName: "",
+ headRefName: "",
+ };
+}
+
+function normalizeNotification(raw: ForgejoNotification): GhNotification {
+ const repoFullName = raw.repository?.full_name ?? "";
+ const repoName = raw.repository?.name ?? repoFullName.split("/").pop() ?? "";
+ const repoHtml = raw.repository?.html_url ?? "";
+ const subjectUrl = raw.subject?.url ?? null;
+ const subjectType = raw.subject?.type ?? "";
+ let itemNumber: number | null = null;
+ if (subjectUrl) {
+ const match = /\/(?:issues|pulls)\/(\d+)/.exec(subjectUrl);
+ if (match) itemNumber = Number(match[1]);
+ }
+ let itemHtmlUrl: string | null = null;
+ if (itemNumber && repoHtml) {
+ if (subjectType === "Pull") itemHtmlUrl = `${repoHtml}/pulls/${itemNumber}`;
+ else if (subjectType === "Issue") itemHtmlUrl = `${repoHtml}/issues/${itemNumber}`;
+ }
+ const reason: GhNotificationReason = "subscribed";
+ return {
+ id: String(raw.id),
+ unread: Boolean(raw.unread),
+ reason,
+ updatedAt: raw.updated_at,
+ lastReadAt: null,
+ subject: {
+ title: raw.subject?.title ?? "",
+ url: subjectUrl,
+ latestCommentUrl: raw.subject?.latest_comment_url ?? null,
+ type: subjectType === "Pull" ? "PullRequest" : (subjectType || ""),
+ },
+ repository: {
+ name: repoName,
+ nameWithOwner: repoFullName,
+ private: Boolean(raw.repository?.private),
+ htmlUrl: repoHtml,
+ },
+ itemNumber,
+ itemHtmlUrl,
+ };
+}
+
+export async function fetchForgejoOwners(account: Account): Promise<{ ok: true; owners: string[] } | { ok: false; error: string; needsAuth?: true }> {
+ const user = await rest(account, "/user");
+ if (!user.ok) {
+ if (user.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
+ return { ok: false, error: `/user: ${user.error}` };
+ }
+ const orgs = await rest(account, "/user/orgs");
+ if (!orgs.ok) {
+ if (orgs.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
+ return { ok: false, error: `/user/orgs: ${orgs.error}` };
+ }
+ const userLogin = user.data.login ?? user.data.username ?? "";
+ const orgLogins = (orgs.data ?? []).map((entry) => entry.username ?? entry.name ?? "").filter(Boolean);
+ const owners = Array.from(new Set([userLogin, ...orgLogins].filter(Boolean)));
+ return { ok: true, owners };
+}
+
+async function fetchReposForOwner(account: Account, owner: string): Promise {
+ // Try user repos first; fall back to org repos if 404.
+ const userResult = await restPaginate(account, `/users/${encodeURIComponent(owner)}/repos`);
+ if (userResult.ok && userResult.data.length > 0) {
+ return userResult.data.map(normalizeRepo);
+ }
+ if (userResult.ok) return [];
+ if (userResult.status !== 404) return [];
+ const orgResult = await restPaginate(account, `/orgs/${encodeURIComponent(owner)}/repos`);
+ if (!orgResult.ok) return [];
+ return orgResult.data.map(normalizeRepo);
+}
+
+export async function fetchForgejoRepos(account: Account, owners: string[]): Promise {
+ const lists = await Promise.all(owners.map((owner) => fetchReposForOwner(account, owner)));
+ const seen = new Set();
+ const result: GhRepo[] = [];
+ for (const list of lists) {
+ for (const repo of list) {
+ if (seen.has(repo.nameWithOwner)) continue;
+ seen.add(repo.nameWithOwner);
+ result.push(repo);
+ }
+ }
+ return result;
+}
+
+async function fetchIssueLikes(account: Account, owners: string[], type: "issues" | "pulls"): Promise {
+ if (!owners.length) return [];
+ const all: ForgejoIssueLike[] = [];
+ for (const owner of owners) {
+ const params = new URLSearchParams({ type, state: "open", owner });
+ const result = await restPaginate(account, `/repos/issues/search?${params.toString()}`, 50, 5);
+ if (result.ok) all.push(...result.data);
+ }
+ return all;
+}
+
+export async function fetchForgejoIssues(account: Account, owners: string[]): Promise {
+ const raws = await fetchIssueLikes(account, owners, "issues");
+ return raws.filter((entry) => !entry.pull_request).map(normalizeIssue);
+}
+
+export async function fetchForgejoPullRequests(account: Account, owners: string[]): Promise {
+ const raws = await fetchIssueLikes(account, owners, "pulls");
+ return raws.map(normalizePullRequest);
+}
+
+export interface ForgejoNotificationsFetchResult {
+ refreshed: boolean;
+ notifications: GhNotification[];
+ pollInterval: number;
+ lastModified: string | null;
+}
+
+export async function fetchForgejoNotifications(account: Account, ifModifiedSince: string | null): Promise {
+ await resolveConfig(account);
+ const config = providerConfigOf(account);
+ const url = `${config.baseUrl}/notifications?all=true&page=1&limit=50`;
+ const headers: Record = { ...authHeaders(account) };
+ if (ifModifiedSince) headers["If-Modified-Since"] = ifModifiedSince;
+ const response = await fetch(url, { headers });
+ if (response.status === 401) return { error: "authentication required", needsAuth: true };
+ const lastModified = response.headers.get("last-modified");
+ if (response.status === 304) {
+ return { refreshed: false, notifications: [], pollInterval: 60, lastModified };
+ }
+ if (!response.ok) {
+ const text = await response.text();
+ return { error: text || `HTTP ${response.status}` };
+ }
+ const raw = (await response.json()) as ForgejoNotification[];
+ return {
+ refreshed: true,
+ notifications: raw.map(normalizeNotification),
+ pollInterval: 60,
+ lastModified,
+ };
+}
+
+export async function markForgejoThreadRead(account: Account, threadId: string): Promise<{ ok: true; status: number } | { ok: false; status: number; error: string; needsAuth?: true }> {
+ const result = await rest(account, `/notifications/threads/${encodeURIComponent(threadId)}`, { method: "PATCH" });
+ if (result.ok) return { ok: true, status: result.status };
+ if (result.status === 401) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
+ return { ok: false, status: result.status, error: result.error };
+}
+
+export async function markForgejoAllRead(account: Account, options: { repo?: string | null; lastReadAt?: string | null }): Promise<{ ok: true; status: number } | { ok: false; status: number; error: string; needsAuth?: true }> {
+ const lastReadAt = options.lastReadAt ?? new Date().toISOString();
+ const params = new URLSearchParams({ last_read_at: lastReadAt });
+ const path = options.repo
+ ? `/repos/${options.repo}/notifications?${params.toString()}`
+ : `/notifications?${params.toString()}`;
+ const result = await rest(account, path, { method: "PUT" });
+ if (result.ok) return { ok: true, status: result.status };
+ if (result.status === 401) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
+ return { ok: false, status: result.status, error: result.error };
+}
diff --git a/src/server/providers/github.ts b/src/server/providers/github.ts
index a9bbd85..c0fa31c 100644
--- a/src/server/providers/github.ts
+++ b/src/server/providers/github.ts
@@ -1,8 +1,22 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
+ GhIssue,
+ GhLabel,
+ GhNotification,
+ GhNotificationReason,
+ GhPullRequest,
+ GhRepo,
+ GhUser,
+ ReviewDecision,
+} from "../../types/github";
+import type {
+ Account,
DeviceFlowPoll,
DeviceFlowStart,
+ NotificationMutationOutcome,
+ NotificationsFetchOutcome,
+ OwnersOutcome,
Provider,
ProviderCapabilities,
ProviderConfig,
@@ -203,6 +217,255 @@ export class GitHubProvider implements Provider {
}
}
+ private restHeaders(account: Account, extra?: Record): Record {
+ return {
+ Accept: "application/vnd.github+json",
+ "User-Agent": this.config.userAgent,
+ Authorization: `Bearer ${account.accessToken}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ ...(extra ?? {}),
+ };
+ }
+
+ private async restGet(account: Account, path: string): Promise<{ ok: true; data: T; status: number } | { ok: false; status: number; error: string }> {
+ const url = path.startsWith("http") ? path : `${this.config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
+ const response = await fetch(url, { headers: this.restHeaders(account) });
+ if (response.status === 204) return { ok: true, data: null as T, status: 204 };
+ const text = await response.text();
+ if (!response.ok) return { ok: false, status: response.status, error: text || `HTTP ${response.status}` };
+ try { return { ok: true, data: JSON.parse(text) as T, status: response.status }; }
+ catch { return { ok: false, status: response.status, error: "invalid JSON" }; }
+ }
+
+ private async gqlCall(account: Account, query: string, variables: Record): Promise {
+ const url = this.config.graphqlUrl ?? `${this.config.baseUrl}/graphql`;
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${account.accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": this.config.userAgent,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ if (response.status === 401) throw new GitHubAuthRequiredError();
+ const json = (await response.json()) as { data?: T; errors?: { message: string }[] };
+ if (json.errors?.length) throw new Error(json.errors.map((entry) => entry.message).join("; "));
+ if (!json.data) throw new Error("Empty GraphQL response");
+ return json.data;
+ }
+
+ async listOwners(account: Account): Promise {
+ try {
+ const user = await this.restGet<{ login: string }>(account, "/user");
+ if (!user.ok) {
+ if (user.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
+ return { ok: false, error: `/user: ${user.error}` };
+ }
+ const orgs = await this.restGet<{ login: string }[]>(account, "/user/orgs");
+ if (!orgs.ok) {
+ if (orgs.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
+ return { ok: false, error: `/user/orgs: ${orgs.error}` };
+ }
+ const owners = Array.from(
+ new Set([user.data.login, ...orgs.data.map((entry) => entry.login)].filter(Boolean)),
+ );
+ return { ok: true, owners };
+ } catch (error) {
+ if (error instanceof GitHubAuthRequiredError) return { ok: false, error: "authentication required", needsAuth: true };
+ return { ok: false, error: (error as Error).message || String(error) };
+ }
+ }
+
+ async listRepos(account: Account, owners: string[]): Promise {
+ const lists = await Promise.all(owners.map((owner) => this.reposForOwner(account, owner)));
+ const seen = new Set();
+ const result: GhRepo[] = [];
+ for (const list of lists) {
+ for (const repo of list) {
+ if (seen.has(repo.nameWithOwner)) continue;
+ seen.add(repo.nameWithOwner);
+ result.push(repo);
+ }
+ }
+ return result;
+ }
+
+ private async reposForOwner(account: Account, owner: string): Promise {
+ const collected: GhRepo[] = [];
+ let cursor: string | null = null;
+ for (let page = 0; page < 10; page++) {
+ let data: RepoListResponse;
+ try {
+ data = await this.gqlCall(account, REPO_LIST_QUERY, { owner, cursor });
+ } catch {
+ return collected;
+ }
+ const owned = data.repositoryOwner?.repositories;
+ if (!owned) return collected;
+ for (const node of owned.nodes) {
+ collected.push({
+ nameWithOwner: node.nameWithOwner,
+ name: node.name,
+ owner: node.owner,
+ description: node.description,
+ stargazerCount: node.stargazerCount,
+ forkCount: node.forkCount,
+ primaryLanguage: node.primaryLanguage,
+ updatedAt: node.updatedAt,
+ pushedAt: node.pushedAt,
+ visibility: node.visibility?.toLowerCase() ?? "",
+ isPrivate: node.isPrivate,
+ isArchived: node.isArchived,
+ isFork: node.isFork,
+ url: node.url,
+ });
+ }
+ if (!owned.pageInfo.hasNextPage) break;
+ cursor = owned.pageInfo.endCursor;
+ if (!cursor) break;
+ }
+ return collected;
+ }
+
+ async listIssues(account: Account, owners: string[]): Promise {
+ if (!owners.length) return [];
+ const q = `is:issue is:open ${owners.map((owner) => `user:${owner}`).join(" ")}`.trim();
+ const collected: GhIssue[] = [];
+ let cursor: string | null = null;
+ for (let page = 0; page < 10; page++) {
+ const data: IssueSearchResponse = await this.gqlCall(account, ISSUE_SEARCH_QUERY, { q, cursor });
+ for (const raw of data.search.nodes) {
+ if (raw.__typename !== "Issue") continue;
+ const node = raw as IssueSearchNode;
+ collected.push({
+ repository: node.repository,
+ title: node.title,
+ url: node.url,
+ number: node.number,
+ createdAt: node.createdAt,
+ updatedAt: node.updatedAt,
+ author: node.author ?? undefined,
+ labels: node.labels?.nodes ?? [],
+ commentsCount: node.comments?.totalCount ?? 0,
+ assignees: node.assignees?.nodes ?? [],
+ });
+ }
+ if (!data.search.pageInfo.hasNextPage) break;
+ cursor = data.search.pageInfo.endCursor;
+ if (!cursor) break;
+ }
+ return collected;
+ }
+
+ async listPullRequests(account: Account, owners: string[]): Promise {
+ if (!owners.length) return [];
+ const q = `is:pr is:open ${owners.map((owner) => `user:${owner}`).join(" ")}`.trim();
+ const collected: GhPullRequest[] = [];
+ let cursor: string | null = null;
+ for (let page = 0; page < 10; page++) {
+ const data: PullRequestSearchResponse = await this.gqlCall(account, PR_SEARCH_QUERY, { q, cursor });
+ for (const raw of data.search.nodes) {
+ if (raw.__typename !== "PullRequest") continue;
+ const node = raw as PullRequestSearchNode;
+ collected.push({
+ repository: node.repository,
+ title: node.title,
+ url: node.url,
+ number: node.number,
+ createdAt: node.createdAt,
+ updatedAt: node.updatedAt,
+ author: node.author ?? undefined,
+ labels: node.labels?.nodes ?? [],
+ commentsCount: node.comments?.totalCount ?? 0,
+ assignees: node.assignees?.nodes ?? [],
+ isDraft: node.isDraft,
+ reviewDecision: node.reviewDecision,
+ reviewsCount: node.reviews?.totalCount ?? 0,
+ additions: node.additions,
+ deletions: node.deletions,
+ changedFiles: node.changedFiles,
+ baseRefName: node.baseRefName,
+ headRefName: node.headRefName,
+ });
+ }
+ if (!data.search.pageInfo.hasNextPage) break;
+ cursor = data.search.pageInfo.endCursor;
+ if (!cursor) break;
+ }
+ return collected;
+ }
+
+ async fetchNotifications(account: Account, ifModifiedSince: string | null): Promise {
+ const initial = `${this.config.baseUrl}/notifications?all=true&participating=false&per_page=50`;
+ const collected: GhNotification[] = [];
+ let url: string | null = initial;
+ let firstLastModified: string | null = null;
+ let firstPollInterval = 60;
+ let firstStatus = 0;
+ let pages = 0;
+ while (url && pages < 5) {
+ const headers = this.restHeaders(account, pages === 0 && ifModifiedSince ? { "If-Modified-Since": ifModifiedSince } : undefined);
+ const response = await fetch(url, { headers });
+ if (response.status === 401) return { ok: false, error: "authentication required", needsAuth: true };
+ const lastModified = response.headers.get("last-modified");
+ const intervalHeader = response.headers.get("x-poll-interval");
+ const pollInterval = intervalHeader ? Math.max(1, Number(intervalHeader)) : 60;
+ if (pages === 0) {
+ firstLastModified = lastModified;
+ firstPollInterval = pollInterval;
+ firstStatus = response.status;
+ if (response.status === 304) {
+ return { ok: true, refreshed: false, notifications: [], lastModified: firstLastModified, pollInterval: firstPollInterval };
+ }
+ }
+ if (!response.ok) {
+ const text = await response.text();
+ return { ok: false, error: text || `HTTP ${response.status}` };
+ }
+ const raw = (await response.json()) as RawGitHubNotification[];
+ for (const entry of raw) collected.push(normalizeGitHubNotification(entry));
+ const link = response.headers.get("link");
+ url = parseNextLink(link);
+ pages += 1;
+ }
+ return {
+ ok: true,
+ refreshed: firstStatus !== 304,
+ notifications: collected,
+ lastModified: firstLastModified,
+ pollInterval: firstPollInterval,
+ };
+ }
+
+ async markNotificationRead(account: Account, threadId: string): Promise {
+ return this.notificationMutate(account, "PATCH", `/notifications/threads/${encodeURIComponent(threadId)}`);
+ }
+
+ async markAllNotificationsRead(account: Account, options: { repo?: string | null; lastReadAt?: string | null }): Promise {
+ const lastReadAt = options.lastReadAt ?? new Date().toISOString();
+ const path = options.repo ? `/repos/${options.repo}/notifications` : "/notifications";
+ return this.notificationMutate(account, "PUT", path, { last_read_at: lastReadAt, read: true });
+ }
+
+ private async notificationMutate(account: Account, method: "PATCH" | "PUT", path: string, body?: unknown): Promise {
+ try {
+ const response = await fetch(`${this.config.baseUrl}${path}`, {
+ method,
+ headers: this.restHeaders(account, body ? { "Content-Type": "application/json" } : undefined),
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (response.status === 401) return { ok: false, status: 401, error: "authentication required", needsAuth: true };
+ if (!response.ok && response.status !== 205) {
+ const text = await response.text();
+ return { ok: false, status: response.status, error: text || `HTTP ${response.status}` };
+ }
+ return { ok: true, status: response.status };
+ } catch (error) {
+ return { ok: false, status: 500, error: (error as Error).message || String(error) };
+ }
+ }
+
avatarUrl(login: string, size = 64): string {
return `${this.config.webUrl}/${encodeURIComponent(login)}.png?size=${size}`;
}
@@ -224,3 +487,203 @@ export class GitHubProvider implements Provider {
}
}
}
+
+export class GitHubAuthRequiredError extends Error {
+ constructor(message = "authentication required") {
+ super(message);
+ this.name = "GitHubAuthRequiredError";
+ }
+}
+
+const REPO_LIST_QUERY = `
+query($owner: String!, $cursor: String) {
+ repositoryOwner(login: $owner) {
+ repositories(first: 100, after: $cursor, ownerAffiliations: [OWNER]) {
+ pageInfo { endCursor hasNextPage }
+ nodes {
+ nameWithOwner name
+ owner { login avatarUrl }
+ description stargazerCount forkCount
+ primaryLanguage { name }
+ updatedAt pushedAt
+ visibility
+ isPrivate isArchived isFork url
+ }
+ }
+ }
+}`;
+
+const ISSUE_SEARCH_QUERY = `
+query($q: String!, $cursor: String) {
+ search(query: $q, type: ISSUE, first: 100, after: $cursor) {
+ issueCount
+ pageInfo { endCursor hasNextPage }
+ nodes {
+ __typename
+ ... on Issue {
+ number title url createdAt updatedAt
+ author { login url }
+ repository { name nameWithOwner }
+ labels(first: 20) { nodes { name color description } }
+ comments { totalCount }
+ assignees(first: 10) { nodes { login url avatarUrl } }
+ }
+ }
+ }
+}`;
+
+const PR_SEARCH_QUERY = `
+query($q: String!, $cursor: String) {
+ search(query: $q, type: ISSUE, first: 100, after: $cursor) {
+ issueCount
+ pageInfo { endCursor hasNextPage }
+ nodes {
+ __typename
+ ... on PullRequest {
+ number title url createdAt updatedAt
+ isDraft reviewDecision
+ author { login url }
+ repository { name nameWithOwner }
+ labels(first: 20) { nodes { name color description } }
+ comments { totalCount }
+ reviews { totalCount }
+ assignees(first: 10) { nodes { login url avatarUrl } }
+ additions deletions changedFiles
+ baseRefName headRefName
+ }
+ }
+ }
+}`;
+
+interface IssueSearchNode {
+ __typename: string;
+ number: number;
+ title: string;
+ url: string;
+ createdAt: string;
+ updatedAt: string;
+ author: { login: string; url: string } | null;
+ repository: { name: string; nameWithOwner: string };
+ labels: { nodes: GhLabel[] };
+ comments: { totalCount: number };
+ assignees: { nodes: GhUser[] };
+}
+
+interface IssueSearchResponse {
+ search: {
+ pageInfo: { endCursor: string | null; hasNextPage: boolean };
+ nodes: (IssueSearchNode | { __typename: string })[];
+ };
+}
+
+interface PullRequestSearchNode {
+ __typename: string;
+ number: number;
+ title: string;
+ url: string;
+ createdAt: string;
+ updatedAt: string;
+ isDraft: boolean;
+ reviewDecision: ReviewDecision;
+ author: { login: string; url: string } | null;
+ repository: { name: string; nameWithOwner: string };
+ labels: { nodes: GhLabel[] };
+ comments: { totalCount: number };
+ reviews: { totalCount: number };
+ assignees: { nodes: GhUser[] };
+ additions: number;
+ deletions: number;
+ changedFiles: number;
+ baseRefName: string;
+ headRefName: string;
+}
+
+interface PullRequestSearchResponse {
+ search: {
+ pageInfo: { endCursor: string | null; hasNextPage: boolean };
+ nodes: (PullRequestSearchNode | { __typename: string })[];
+ };
+}
+
+interface RepoNode {
+ nameWithOwner: string;
+ name: string;
+ owner: { login: string; avatarUrl?: string };
+ description: string | null;
+ stargazerCount: number;
+ forkCount: number;
+ primaryLanguage: { name: string } | null;
+ updatedAt: string;
+ pushedAt: string;
+ visibility: string;
+ isPrivate: boolean;
+ isArchived: boolean;
+ isFork: boolean;
+ url: string;
+}
+
+interface RepoListResponse {
+ repositoryOwner: {
+ repositories: {
+ pageInfo: { endCursor: string | null; hasNextPage: boolean };
+ nodes: RepoNode[];
+ };
+ } | null;
+}
+
+interface RawGitHubNotification {
+ id: string;
+ unread: boolean;
+ reason: string;
+ updated_at: string;
+ last_read_at: string | null;
+ subject: { title: string; url: string | null; latest_comment_url: string | null; type: string };
+ repository: { name: string; full_name: string; private: boolean; html_url: string };
+}
+
+function parseNextLink(header: string | null): string | null {
+ if (!header) return null;
+ for (const part of header.split(",")) {
+ const match = /<([^>]+)>;\s*rel="next"/.exec(part.trim());
+ if (match) return match[1];
+ }
+ return null;
+}
+
+const SUBJECT_NUMBER_PATTERN = /\/(?:issues|pulls)\/(\d+)$/;
+
+function normalizeGitHubNotification(raw: RawGitHubNotification): GhNotification {
+ const itemNumber = (() => {
+ if (!raw.subject?.url) return null;
+ const match = SUBJECT_NUMBER_PATTERN.exec(raw.subject.url);
+ return match ? Number(match[1]) : null;
+ })();
+ const repoHtml = raw.repository?.html_url ?? "";
+ const subjectType = raw.subject?.type ?? "";
+ let itemHtmlUrl: string | null = null;
+ if (itemNumber && raw.subject?.url) {
+ if (subjectType === "PullRequest") itemHtmlUrl = `${repoHtml}/pull/${itemNumber}`;
+ else if (subjectType === "Issue") itemHtmlUrl = `${repoHtml}/issues/${itemNumber}`;
+ }
+ return {
+ id: raw.id,
+ unread: Boolean(raw.unread),
+ reason: raw.reason as GhNotificationReason,
+ updatedAt: raw.updated_at,
+ lastReadAt: raw.last_read_at,
+ subject: {
+ title: raw.subject?.title ?? "",
+ url: raw.subject?.url ?? null,
+ latestCommentUrl: raw.subject?.latest_comment_url ?? null,
+ type: subjectType,
+ },
+ repository: {
+ name: raw.repository?.name ?? "",
+ nameWithOwner: raw.repository?.full_name ?? "",
+ private: Boolean(raw.repository?.private),
+ htmlUrl: repoHtml,
+ },
+ itemNumber,
+ itemHtmlUrl,
+ };
+}
diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts
index df148ca..30e6503 100644
--- a/src/server/providers/types.ts
+++ b/src/server/providers/types.ts
@@ -71,6 +71,49 @@ export interface ProviderIdentity {
htmlUrl?: string | null;
}
+export interface OwnersResult {
+ ok: true;
+ owners: string[];
+}
+
+export interface OwnersError {
+ ok: false;
+ error: string;
+ needsAuth?: true;
+}
+
+export type OwnersOutcome = OwnersResult | OwnersError;
+
+export interface NotificationsFetchOk {
+ ok: true;
+ refreshed: boolean;
+ notifications: import("../../types/github").GhNotification[];
+ pollInterval: number;
+ lastModified: string | null;
+}
+
+export interface NotificationsFetchError {
+ ok: false;
+ error: string;
+ needsAuth?: true;
+}
+
+export type NotificationsFetchOutcome = NotificationsFetchOk | NotificationsFetchError;
+
+export interface NotificationMutationOk {
+ ok: true;
+ status: number;
+}
+
+export interface NotificationMutationError {
+ ok: false;
+ status: number;
+ error: string;
+ needsAuth?: true;
+}
+
+export type NotificationMutationOutcome = NotificationMutationOk | NotificationMutationError;
+
export interface Provider {
readonly kind: ProviderKind;
readonly config: ProviderConfig;
@@ -81,6 +124,27 @@ export interface Provider {
fetchIdentity(token: string): Promise;
loadFromGhCli?(): Promise<{ token: string } | null>;
+ listOwners(account: Account): Promise;
+ listRepos(
+ account: Account,
+ owners: string[],
+ ): Promise;
+ listIssues(
+ account: Account,
+ owners: string[],
+ ): Promise;
+ listPullRequests(
+ account: Account,
+ owners: string[],
+ ): Promise;
+
+ fetchNotifications(account: Account, ifModifiedSince: string | null): Promise;
+ markNotificationRead(account: Account, threadId: string): Promise;
+ markAllNotificationsRead(
+ account: Account,
+ options: { repo?: string | null; lastReadAt?: string | null },
+ ): Promise;
+
avatarUrl(login: string, size?: number): string;
webUrlFor(kind: "user" | "repo" | "issue" | "pr", parts: Record): string;
}
From 5b29b58c1b197f1d4ef7e0fdd6c53a9b09c74e86 Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 09:34:36 +0200
Subject: [PATCH 7/9] fix(auth): provider choice on the first-time sign-in
screen
AuthGate used to start the GitHub device flow immediately. Now it
loads the provider configs and shows the same picker the Add account
modal uses, so a brand-new install can land directly on Codeberg via
a personal access token without ever touching GitHub.
External auth modes (gh-cli, GITHUB_TOKEN) keep their existing
diagnostic panel.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/components/AuthGate.tsx | 182 +++++++++++++++++++++++++++---------
1 file changed, 139 insertions(+), 43 deletions(-)
diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx
index 220ca12..e7c7061 100644
--- a/src/components/AuthGate.tsx
+++ b/src/components/AuthGate.tsx
@@ -1,10 +1,13 @@
import { useEffect, useRef, useState } from "react";
import {
+ addTokenAccount,
fetchAuthStatus,
+ fetchProviderConfigs,
pollAuthFlow,
startAuthFlow,
type AuthStatus,
type DeviceFlowStart,
+ type ProviderConfigSummary,
} from "../api/github";
import appLogo from "../assets/app-logo-mark.svg";
import { useI18n } from "../i18n/I18nProvider";
@@ -13,21 +16,28 @@ interface AuthGateProps {
onAuthenticated: (login: string) => void;
}
-type Phase = "idle" | "starting" | "awaiting" | "verifying" | "success" | "error";
+type Step = "choose" | "device" | "token" | "success";
+type DevicePhase = "starting" | "awaiting" | "error";
export function AuthGate({ onAuthenticated }: AuthGateProps) {
const { t } = useI18n();
const [status, setStatus] = useState(null);
+ const [configs, setConfigs] = useState([]);
+ const [step, setStep] = useState("choose");
+ const [selected, setSelected] = useState(null);
const [flow, setFlow] = useState(null);
- const [phase, setPhase] = useState("idle");
+ const [devicePhase, setDevicePhase] = useState("starting");
+ const [token, setToken] = useState("");
+ const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
const intervalRef = useRef(null);
useEffect(() => {
- void fetchAuthStatus()
- .then(setStatus)
- .catch(() => setStatus(null));
+ void fetchAuthStatus().then(setStatus).catch(() => setStatus(null));
+ void fetchProviderConfigs()
+ .then((res) => setConfigs(res.configs))
+ .catch((err) => setError((err as Error).message));
}, []);
useEffect(() => () => {
@@ -47,46 +57,52 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
if (!("status" in result)) return;
if (result.status === "ok") {
stopPolling();
- setPhase("success");
+ setStep("success");
onAuthenticated(result.login);
return;
}
if (result.status === "expired") {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError(t("auth.expired"));
return;
}
if (result.status === "denied") {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError(t("auth.denied"));
return;
}
if (result.status === "error") {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError(result.error);
}
} catch (err) {
stopPolling();
- setPhase("error");
+ setDevicePhase("error");
setError((err as Error).message);
}
}
- async function start() {
+ async function pickProvider(config: ProviderConfigSummary) {
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);
+ setSelected(config);
+ if (config.supportsDeviceFlow) {
+ setStep("device");
+ setDevicePhase("starting");
+ try {
+ const data = await startAuthFlow();
+ setFlow(data);
+ setDevicePhase("awaiting");
+ const intervalMs = Math.max(2, data.interval) * 1000;
+ intervalRef.current = window.setInterval(() => void poll(), intervalMs);
+ } catch (err) {
+ setDevicePhase("error");
+ setError((err as Error).message);
+ }
+ } else {
+ setStep("token");
}
}
@@ -97,13 +113,50 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
- // Clipboard may be unavailable; users can copy manually.
+ // clipboard unavailable
+ }
+ }
+
+ async function submitToken(event: React.FormEvent) {
+ event.preventDefault();
+ if (!selected) return;
+ const trimmed = token.trim();
+ if (!trimmed) {
+ setError(t("accounts.tokenRequired"));
+ return;
+ }
+ setError("");
+ setSubmitting(true);
+ try {
+ await addTokenAccount({ providerConfigId: selected.id, token: trimmed });
+ setStep("success");
+ try {
+ const refreshed = await fetchAuthStatus();
+ onAuthenticated(refreshed.login ?? selected.label);
+ } catch {
+ onAuthenticated(selected.label);
+ }
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setSubmitting(false);
}
}
- const clientMissing = status?.clientIdConfigured === false;
+ function backToChoice() {
+ stopPolling();
+ setStep("choose");
+ setSelected(null);
+ setFlow(null);
+ setToken("");
+ setError("");
+ setCopied(false);
+ setDevicePhase("starting");
+ }
+
const mode = status?.mode ?? "device";
const externalMode = mode === "gh-cli" || mode === "token";
+ const clientMissing = status?.clientIdConfigured === false && selected?.supportsDeviceFlow;
return (
@@ -113,9 +166,7 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
GitHub Dashboard
{t("auth.signIn")}
-
- {t("auth.description")}
-
+ {t("auth.description")}
{externalMode ? (
@@ -134,14 +185,36 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
{", "}{t("auth.ghCliReload")}
>
) : (
- <>
- {t("auth.tokenHelp")}
- >
+ <>{t("auth.tokenHelp")}>
)}
{status?.detail ?
{status.detail}
: null}
- ) : clientMissing ? (
+ ) : null}
+
+ {!externalMode && step === "choose" ? (
+
+
{t("accounts.pickProvider")}
+ {configs.length === 0 && !error ?
{t("common.loading")}
: null}
+ {configs.map((config) => (
+
void pickProvider(config)}
+ >
+ {config.label}
+
+ {config.kind === "github" && config.supportsDeviceFlow
+ ? t("accounts.viaDeviceFlow")
+ : t("accounts.viaToken")}
+
+
+ ))}
+
+ ) : null}
+
+ {clientMissing && step === "device" ? (
{t("auth.clientMissing")}
@@ -154,25 +227,19 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
) : null}
- {!externalMode && (phase === "idle" || phase === "error") ? (
- void start()} disabled={clientMissing}>
- {t("auth.continue")}
-
+ {step === "device" && devicePhase === "starting" ? (
+ {t("auth.requestingCode")}
) : null}
- {phase === "starting" ? {t("auth.requestingCode")}
: null}
-
- {phase === "awaiting" && flow ? (
+ {step === "device" && devicePhase === "awaiting" && flow ? (
-
- {t("auth.openVerification")}
-
+
{t("auth.openVerification")}
{flow.verificationUri}
{flow.userCode}
- void copyCode()}>
+ void copyCode()}>
{copied ? t("auth.copied") : t("auth.copy")}
@@ -180,11 +247,40 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
) : null}
- {phase === "success" ? (
- {t("auth.success")}
+ {step === "token" && selected ? (
+
) : null}
+ {step === "success" ? {t("auth.success")}
: null}
+
{error ? {error}
: null}
+
+ {(step === "device" || step === "token") && !externalMode ? (
+
+ ←
+
+ ) : null}
);
From 57fb259aab2a6d3d651656cc791381f49d13b9ac Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 17:34:33 +0200
Subject: [PATCH 8/9] feat(auth): redesign auth UI and update app title Update
AuthGate markup and styles; update app.title i18n and index.html title
---
index.html | 2 +-
src/components/AuthGate.tsx | 187 ++++++++++----
src/components/TopBar.tsx | 2 +-
src/i18n/de.ts | 12 +-
src/i18n/en.ts | 12 +-
src/i18n/es.ts | 12 +-
src/i18n/fr.ts | 12 +-
src/i18n/it.ts | 12 +-
src/i18n/zh.ts | 12 +-
src/styles/auth.css | 499 +++++++++++++++++++++++++++++++-----
10 files changed, 612 insertions(+), 150 deletions(-)
diff --git a/index.html b/index.html
index cecac5a..d890d23 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,7 @@
- GitHub Dashboard
+ Git Dashboard
diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx
index e7c7061..caf3ff6 100644
--- a/src/components/AuthGate.tsx
+++ b/src/components/AuthGate.tsx
@@ -157,60 +157,88 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
const mode = status?.mode ?? "device";
const externalMode = mode === "gh-cli" || mode === "token";
const clientMissing = status?.clientIdConfigured === false && selected?.supportsDeviceFlow;
+ const showBack = !externalMode && (step === "device" || step === "token");
return (
+
-
-
-
GitHub Dashboard
-
-
{t("auth.signIn")}
-
{t("auth.description")}
+
+
+
+
+
+ {t("app.title")}
+ {t("auth.brandTag")}
+
+
{externalMode ? (
-
-
- {mode === "gh-cli"
- ? t("auth.ghCliNotReady")
- : t("auth.tokenMissing")}
-
-
- {mode === "gh-cli" ? (
- <>
- {t("auth.ghCliHelp")}{" "}
- gh CLI
-
- gh auth login
- {", "}{t("auth.ghCliReload")}
- >
- ) : (
- <>{t("auth.tokenHelp")}>
- )}
-
- {status?.detail ?
{status.detail}
: null}
-
+ <>
+
{t("auth.signIn")}
+
+
+ {mode === "gh-cli" ? t("auth.ghCliNotReady") : t("auth.tokenMissing")}
+
+
+ {mode === "gh-cli" ? (
+ <>
+ {t("auth.ghCliHelp")}{" "}
+ gh CLI
+
+ gh auth login
+ {", "}{t("auth.ghCliReload")}
+ >
+ ) : (
+ <>{t("auth.tokenHelp")}>
+ )}
+
+ {status?.detail ?
{status.detail}
: null}
+
+ >
) : null}
{!externalMode && step === "choose" ? (
-
-
{t("accounts.pickProvider")}
- {configs.length === 0 && !error ?
{t("common.loading")}
: null}
- {configs.map((config) => (
-
void pickProvider(config)}
- >
- {config.label}
-
- {config.kind === "github" && config.supportsDeviceFlow
- ? t("accounts.viaDeviceFlow")
- : t("accounts.viaToken")}
-
-
- ))}
+ <>
+
{t("auth.signIn")}
+
{t("auth.description")}
+
+ {configs.length === 0 && !error ? (
+
{t("common.loading")}
+ ) : null}
+ {configs.map((config) => (
+
void pickProvider(config)}
+ >
+
+
+ {config.label}
+
+ {config.kind === "github" && config.supportsDeviceFlow
+ ? t("accounts.viaDeviceFlow")
+ : t("accounts.viaToken")}
+
+
+
+
+ ))}
+
+ >
+ ) : null}
+
+ {!externalMode && (step === "device" || step === "token") && selected ? (
+
+
+
+ {selected.label}
+
+ {selected.supportsDeviceFlow ? t("accounts.viaDeviceFlow") : t("accounts.viaToken")}
+
+
) : null}
@@ -236,6 +264,7 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
{t("auth.openVerification")}
{flow.verificationUri}
+
{flow.userCode}
@@ -243,26 +272,30 @@ export function AuthGate({ onAuthenticated }: AuthGateProps) {
{copied ? t("auth.copied") : t("auth.copy")}
-
{t("auth.waiting")}
+
+
+ {t("auth.waiting")}
+
) : null}
{step === "token" && selected ? (
-
) : null}
- {step === "success" ?
{t("auth.success")}
: null}
+ {step === "success" ? (
+
+
✓
+
{t("auth.success")}
+
+ ) : null}
{error ?
{error}
: null}
- {(step === "device" || step === "token") && !externalMode ? (
-
- ←
+ {showBack ? (
+
+ ← {t("auth.changeProvider")}
) : null}
);
}
+
+function ProviderBadge({ config, small = false }: { config: ProviderConfigSummary; small?: boolean }) {
+ const className = `auth-provider-badge auth-provider-badge-${config.kind}${small ? " auth-provider-badge-sm" : ""}`;
+ if (config.kind === "github") {
+ return (
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ChevronIcon() {
+ return (
+
+
+
+ );
+}
+
+function ExternalIcon() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx
index 7ab7566..ae2e0b4 100644
--- a/src/components/TopBar.tsx
+++ b/src/components/TopBar.tsx
@@ -80,7 +80,7 @@ export function TopBar({
))}
-
GitHub Dashboard
+
{t("app.title")}
{subtitle}
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 08b01ae..88e6a70 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const de: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "Sprache",
"language.en": "English",
"language.it": "Italiano",
@@ -178,8 +178,10 @@ export const de: Record = {
"preset.authoredMe": "Von mir erstellt",
"preset.stale": "Inaktiv",
"confirm.markAllRead": "{count} GitHub-Benachrichtigung(en) als gelesen markieren?",
- "auth.signIn": "Mit GitHub anmelden",
- "auth.description": "Dieses Dashboard liest deine Repositories und Issues über die GitHub API. Autorisiere die App, um fortzufahren.",
+ "auth.signIn": "Account verbinden",
+ "auth.description": "Füge GitHub oder eine Forgejo-kompatible Instanz (Codeberg, selbst gehostet) hinzu, um Repositories, Issues und Benachrichtigungen zu lesen.",
+ "auth.brandTag": "Multi-Account-Dashboard",
+ "auth.changeProvider": "Provider wechseln",
"auth.ghCliNotReady": "Authentifizierung über gh CLI ist nicht bereit.",
"auth.tokenMissing": "GITHUB_TOKEN ist nicht verfügbar.",
"auth.ghCliHelp": "Der Server ist mit GH_AUTH_MODE=gh-cli konfiguriert. Stelle sicher, dass gh CLI installiert ist und du angemeldet bist:",
@@ -187,9 +189,9 @@ export const de: Record = {
"auth.tokenHelp": "Der Server ist mit GH_AUTH_MODE=token konfiguriert. Exportiere ein Personal Access Token als GITHUB_TOKEN und starte den Server neu.",
"auth.clientMissing": "GITHUB_CLIENT_ID ist nicht gesetzt.",
"auth.clientHelp": "Registriere eine OAuth App unter github.com/settings/developers, aktiviere Device Flow, exportiere GITHUB_CLIENT_ID und starte den Server neu. Alternativ setze GH_AUTH_MODE=gh-cli, um deine lokale gh CLI Sitzung zu verwenden, oder GH_AUTH_MODE=token mit einem GITHUB_TOKEN.",
- "auth.continue": "Mit GitHub fortfahren",
+ "auth.continue": "Fortfahren",
"auth.requestingCode": "Gerätecode wird angefordert...",
- "auth.openVerification": "Öffne die GitHub-Verifizierungsseite und gib den Code unten ein.",
+ "auth.openVerification": "Öffne die Verifizierungsseite des Providers und gib den Code unten ein.",
"auth.copied": "Kopiert",
"auth.copy": "Kopieren",
"auth.waiting": "Warte auf Autorisierung...",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 22a999c..00ddf6d 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -1,5 +1,5 @@
export const en = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "Language",
"language.en": "English",
"language.it": "Italiano",
@@ -176,8 +176,10 @@ export const en = {
"preset.authoredMe": "Authored by me",
"preset.stale": "Stale",
"confirm.markAllRead": "Mark {count} notification{plural} as read on GitHub?",
- "auth.signIn": "Sign in with GitHub",
- "auth.description": "This dashboard reads your repositories and issues via the GitHub API. Authorize the app to continue.",
+ "auth.signIn": "Connect an account",
+ "auth.description": "Add GitHub or a Forgejo-compatible instance (Codeberg, self-hosted) to read repositories, issues and notifications.",
+ "auth.brandTag": "Multi-account dashboard",
+ "auth.changeProvider": "Change provider",
"auth.ghCliNotReady": "Authentication via gh CLI is not ready.",
"auth.tokenMissing": "GITHUB_TOKEN is not available.",
"auth.ghCliHelp": "The server is configured with GH_AUTH_MODE=gh-cli. Make sure the gh CLI is installed and you are signed in:",
@@ -185,9 +187,9 @@ export const en = {
"auth.tokenHelp": "The server is configured with GH_AUTH_MODE=token. Export a personal access token as GITHUB_TOKEN and restart the server.",
"auth.clientMissing": "GITHUB_CLIENT_ID is not set.",
"auth.clientHelp": "Register an OAuth App at github.com/settings/developers, enable Device Flow, then export GITHUB_CLIENT_ID and restart the server. Alternatively, set GH_AUTH_MODE=gh-cli to reuse your local gh CLI session, or GH_AUTH_MODE=token with a GITHUB_TOKEN.",
- "auth.continue": "Continue with GitHub",
+ "auth.continue": "Continue",
"auth.requestingCode": "Requesting device code...",
- "auth.openVerification": "Open the GitHub verification page and enter the code below.",
+ "auth.openVerification": "Open the verification page on your provider and enter the code below.",
"auth.copied": "Copied",
"auth.copy": "Copy",
"auth.waiting": "Waiting for authorization...",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 8d65738..419025f 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const es: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "Idioma",
"language.en": "English",
"language.it": "Italiano",
@@ -178,8 +178,10 @@ export const es: Record = {
"preset.authoredMe": "Creadas por mí",
"preset.stale": "Inactivas",
"confirm.markAllRead": "¿Marcar {count} notificación(es) como leída(s) en GitHub?",
- "auth.signIn": "Iniciar sesión con GitHub",
- "auth.description": "Este dashboard lee tus repositorios e issues mediante la API de GitHub. Autoriza la app para continuar.",
+ "auth.signIn": "Conecta una cuenta",
+ "auth.description": "Añade GitHub o una instancia compatible con Forgejo (Codeberg, autohospedada) para leer repositorios, issues y notificaciones.",
+ "auth.brandTag": "Dashboard multi-cuenta",
+ "auth.changeProvider": "Cambiar proveedor",
"auth.ghCliNotReady": "La autenticación con gh CLI no está lista.",
"auth.tokenMissing": "GITHUB_TOKEN no está disponible.",
"auth.ghCliHelp": "El servidor está configurado con GH_AUTH_MODE=gh-cli. Asegúrate de que gh CLI esté instalada y hayas iniciado sesión:",
@@ -187,9 +189,9 @@ export const es: Record = {
"auth.tokenHelp": "El servidor está configurado con GH_AUTH_MODE=token. Exporta un personal access token como GITHUB_TOKEN y reinicia el servidor.",
"auth.clientMissing": "GITHUB_CLIENT_ID no está definido.",
"auth.clientHelp": "Registra una OAuth App en github.com/settings/developers, habilita Device Flow, luego exporta GITHUB_CLIENT_ID y reinicia el servidor. También puedes usar GH_AUTH_MODE=gh-cli para reutilizar tu sesión local de gh CLI, o GH_AUTH_MODE=token con un GITHUB_TOKEN.",
- "auth.continue": "Continuar con GitHub",
+ "auth.continue": "Continuar",
"auth.requestingCode": "Solicitando código del dispositivo...",
- "auth.openVerification": "Abre la página de verificación de GitHub e introduce el código inferior.",
+ "auth.openVerification": "Abre la página de verificación del proveedor e introduce el código inferior.",
"auth.copied": "Copiado",
"auth.copy": "Copiar",
"auth.waiting": "Esperando autorización...",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 1a4a948..2545e6f 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const fr: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "Langue",
"language.en": "English",
"language.it": "Italiano",
@@ -178,8 +178,10 @@ export const fr: Record = {
"preset.authoredMe": "Créé par moi",
"preset.stale": "Inactif",
"confirm.markAllRead": "Marquer {count} notification{plural} comme lue sur GitHub ?",
- "auth.signIn": "Se connecter avec GitHub",
- "auth.description": "Ce tableau de bord lit vos dépôts et issues via l'API GitHub. Autorisez l'application pour continuer.",
+ "auth.signIn": "Connecter un compte",
+ "auth.description": "Ajoutez GitHub ou une instance compatible Forgejo (Codeberg, auto-hébergée) pour lire dépôts, issues et notifications.",
+ "auth.brandTag": "Tableau de bord multi-comptes",
+ "auth.changeProvider": "Changer de fournisseur",
"auth.ghCliNotReady": "L'authentification via gh CLI n'est pas prête.",
"auth.tokenMissing": "GITHUB_TOKEN n'est pas disponible.",
"auth.ghCliHelp": "Le serveur est configuré avec GH_AUTH_MODE=gh-cli. Vérifiez que gh CLI est installée et que vous êtes connecté :",
@@ -187,9 +189,9 @@ export const fr: Record = {
"auth.tokenHelp": "Le serveur est configuré avec GH_AUTH_MODE=token. Exportez un personal access token en GITHUB_TOKEN et redémarrez le serveur.",
"auth.clientMissing": "GITHUB_CLIENT_ID n'est pas défini.",
"auth.clientHelp": "Enregistrez une OAuth App sur github.com/settings/developers, activez Device Flow, puis exportez GITHUB_CLIENT_ID et redémarrez le serveur. Sinon, définissez GH_AUTH_MODE=gh-cli pour réutiliser votre session gh CLI locale, ou GH_AUTH_MODE=token avec un GITHUB_TOKEN.",
- "auth.continue": "Continuer avec GitHub",
+ "auth.continue": "Continuer",
"auth.requestingCode": "Demande du code appareil...",
- "auth.openVerification": "Ouvrez la page de vérification GitHub et saisissez le code ci-dessous.",
+ "auth.openVerification": "Ouvrez la page de vérification du fournisseur et saisissez le code ci-dessous.",
"auth.copied": "Copié",
"auth.copy": "Copier",
"auth.waiting": "En attente d'autorisation...",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 28dc505..1dbceff 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const it: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "Lingua",
"language.en": "English",
"language.it": "Italiano",
@@ -178,8 +178,10 @@ export const it: Record = {
"preset.authoredMe": "Create da me",
"preset.stale": "Inattive",
"confirm.markAllRead": "Vuoi segnare {count} notifica{plural} come lette su GitHub?",
- "auth.signIn": "Accedi con GitHub",
- "auth.description": "Questa dashboard legge i tuoi repository e le issue tramite l'API di GitHub. Autorizza l'app per continuare.",
+ "auth.signIn": "Connetti un account",
+ "auth.description": "Aggiungi GitHub o un'istanza Forgejo (Codeberg, self-hosted) per leggere repository, issue e notifiche.",
+ "auth.brandTag": "Dashboard multi-account",
+ "auth.changeProvider": "Cambia provider",
"auth.ghCliNotReady": "L'autenticazione tramite gh CLI non è pronta.",
"auth.tokenMissing": "GITHUB_TOKEN non è disponibile.",
"auth.ghCliHelp": "Il server è configurato con GH_AUTH_MODE=gh-cli. Verifica che gh CLI sia installato e di aver effettuato l'accesso:",
@@ -187,9 +189,9 @@ export const it: Record = {
"auth.tokenHelp": "Il server è configurato con GH_AUTH_MODE=token. Esporta un personal access token come GITHUB_TOKEN e riavvia il server.",
"auth.clientMissing": "GITHUB_CLIENT_ID non è impostato.",
"auth.clientHelp": "Registra una OAuth App su github.com/settings/developers, abilita il Device Flow, poi esporta GITHUB_CLIENT_ID e riavvia il server. In alternativa, imposta GH_AUTH_MODE=gh-cli per riutilizzare la sessione locale di gh CLI, oppure GH_AUTH_MODE=token con un GITHUB_TOKEN.",
- "auth.continue": "Continua con GitHub",
+ "auth.continue": "Continua",
"auth.requestingCode": "Richiesta codice dispositivo...",
- "auth.openVerification": "Apri la pagina di verifica di GitHub e inserisci il codice qui sotto.",
+ "auth.openVerification": "Apri la pagina di verifica del provider e inserisci il codice qui sotto.",
"auth.copied": "Copiato",
"auth.copy": "Copia",
"auth.waiting": "In attesa di autorizzazione...",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 18ce4c7..68d7700 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const zh: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Git Dashboard",
"language.label": "语言",
"language.en": "English",
"language.it": "Italiano",
@@ -178,8 +178,10 @@ export const zh: Record = {
"preset.authoredMe": "我创建的",
"preset.stale": "停滞",
"confirm.markAllRead": "将 {count} 条通知在 GitHub 上标为已读?",
- "auth.signIn": "使用 GitHub 登录",
- "auth.description": "此 dashboard 通过 GitHub API 读取你的仓库和 issues。请授权应用以继续。",
+ "auth.signIn": "连接账户",
+ "auth.description": "添加 GitHub 或 Forgejo 兼容实例(如 Codeberg、自托管),即可读取仓库、issues 和通知。",
+ "auth.brandTag": "多账户面板",
+ "auth.changeProvider": "更换提供方",
"auth.ghCliNotReady": "gh CLI 认证尚未就绪。",
"auth.tokenMissing": "GITHUB_TOKEN 不可用。",
"auth.ghCliHelp": "服务器配置为 GH_AUTH_MODE=gh-cli。请确认已安装 gh CLI 并已登录:",
@@ -187,9 +189,9 @@ export const zh: Record = {
"auth.tokenHelp": "服务器配置为 GH_AUTH_MODE=token。请将 personal access token 导出为 GITHUB_TOKEN,然后重启服务器。",
"auth.clientMissing": "未设置 GITHUB_CLIENT_ID。",
"auth.clientHelp": "在 github.com/settings/developers 注册 OAuth App,启用 Device Flow,然后导出 GITHUB_CLIENT_ID 并重启服务器。也可以设置 GH_AUTH_MODE=gh-cli 复用本地 gh CLI 会话,或使用 GH_AUTH_MODE=token 加 GITHUB_TOKEN。",
- "auth.continue": "继续使用 GitHub",
+ "auth.continue": "继续",
"auth.requestingCode": "正在请求设备代码...",
- "auth.openVerification": "打开 GitHub 验证页面并输入下面的代码。",
+ "auth.openVerification": "打开提供方验证页面并输入下面的代码。",
"auth.copied": "已复制",
"auth.copy": "复制",
"auth.waiting": "等待授权...",
diff --git a/src/styles/auth.css b/src/styles/auth.css
index c80940c..7511adb 100644
--- a/src/styles/auth.css
+++ b/src/styles/auth.css
@@ -1,135 +1,504 @@
.auth-gate {
+ position: relative;
min-height: 100vh;
display: grid;
place-items: center;
- padding: 24px;
+ padding: 32px 20px;
+ overflow: hidden;
+}
+
+.auth-aura {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(620px 320px at 22% 18%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%),
+ radial-gradient(560px 340px at 82% 88%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 72%);
+ filter: blur(2px);
+ opacity: 0.9;
}
.auth-card {
- background: var(--surface, #1f2937);
- color: var(--text);
- border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
- border-radius: 14px;
- padding: 32px;
- max-width: 420px;
+ position: relative;
width: 100%;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
+ max-width: 440px;
+ padding: 28px;
+ border-radius: 18px;
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
+ border: 1px solid var(--border-soft);
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
+ 0 30px 80px rgba(0, 0, 0, 0.45),
+ 0 1px 0 rgba(0, 0, 0, 0.4);
+ backdrop-filter: saturate(140%) blur(12px);
+ -webkit-backdrop-filter: saturate(140%) blur(12px);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.auth-card::before {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(140deg, color-mix(in srgb, var(--accent) 35%, transparent), transparent 45%, color-mix(in srgb, var(--accent-2) 28%, transparent));
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.auth-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 4px;
+}
+
+.auth-brand-logo {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ overflow: hidden;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 12px 28px var(--accent-shadow);
+}
+
+.auth-brand-logo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.auth-brand-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 1px;
+}
+
+.auth-brand-name {
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: var(--text);
}
-.auth-card h1 {
+.auth-brand-tag {
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+}
+
+.auth-title {
+ margin: 6px 0 0;
font-size: 22px;
- margin: 0 0 8px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
}
.auth-sub {
- margin: 0 0 20px;
- color: var(--muted, rgba(255, 255, 255, 0.65));
- font-size: 14px;
- line-height: 1.5;
+ margin: 0;
+ color: var(--muted);
+ font-size: 13.5px;
+ line-height: 1.55;
}
-.auth-primary {
- display: inline-flex;
- align-items: center;
- justify-content: center;
+.auth-providers {
+ display: flex;
+ flex-direction: column;
gap: 8px;
+ margin-top: 4px;
+}
+
+.auth-provider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
width: 100%;
- padding: 10px 14px;
- border-radius: 10px;
- border: 1px solid transparent;
- background: var(--accent, #2f81f7);
- color: #fff;
- font-weight: 600;
+ padding: 12px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--border-soft);
+ background: color-mix(in srgb, var(--panel-2) 90%, transparent);
+ color: var(--text);
cursor: pointer;
+ text-align: left;
+ font: inherit;
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
}
-.auth-primary:disabled {
- opacity: 0.5;
- cursor: not-allowed;
+.auth-provider:hover {
+ border-color: var(--accent-border);
+ background: var(--panel-2);
+ transform: translateY(-1px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
-.auth-secondary {
- padding: 6px 12px;
+.auth-provider:focus-visible {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: var(--ring);
+}
+
+.auth-provider-body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+
+.auth-provider-label {
+ font-weight: 700;
+ font-size: 14px;
+ color: var(--text);
+}
+
+.auth-provider-meta {
+ font-size: 11.5px;
+ color: var(--muted);
+ letter-spacing: 0.01em;
+}
+
+.auth-provider-chevron {
+ color: var(--muted);
+ transition: transform 120ms ease, color 120ms ease;
+ flex: 0 0 auto;
+}
+
+.auth-provider:hover .auth-provider-chevron {
+ color: var(--accent);
+ transform: translateX(2px);
+}
+
+.auth-provider-badge {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: #fff;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+
+.auth-provider-badge-sm {
+ width: 28px;
+ height: 28px;
border-radius: 8px;
- border: 1px solid var(--border, rgba(255, 255, 255, 0.18));
- background: transparent;
- color: inherit;
- cursor: pointer;
+}
+
+.auth-provider-badge-sm svg {
+ width: 16px;
+ height: 16px;
+}
+
+.auth-provider-badge-github {
+ background: linear-gradient(155deg, #2b333e, #0d1117);
+}
+
+.auth-provider-badge-forgejo {
+ background: linear-gradient(155deg, #ff8e3c, #ed591b);
+}
+
+.auth-provider-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--panel-2) 85%, transparent);
+ border: 1px solid var(--border-soft);
+}
+
+.auth-provider-header-text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.auth-provider-header-label {
font-size: 13px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.auth-provider-header-meta {
+ font-size: 11px;
+ color: var(--muted);
}
.auth-status {
- margin: 16px 0 0;
- font-size: 14px;
- color: var(--muted, rgba(255, 255, 255, 0.65));
+ margin: 0;
+ font-size: 13.5px;
+ color: var(--muted);
+ line-height: 1.5;
}
-.auth-flow {
- margin-top: 16px;
+.auth-flow,
+.auth-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth-link {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
padding: 8px 12px;
- border-radius: 8px;
- background: rgba(47, 129, 247, 0.12);
- color: var(--accent, #2f81f7);
- font-size: 13px;
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ color: var(--accent);
+ font-size: 12.5px;
word-break: break-all;
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
+ text-decoration: none;
+ transition: background 120ms ease, border-color 120ms ease;
}
.auth-link:hover {
- text-decoration: underline;
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
+ border-color: color-mix(in srgb, var(--accent) 38%, transparent);
+ text-decoration: none;
+}
+
+.auth-link svg {
+ flex: 0 0 auto;
+ opacity: 0.8;
}
.auth-code-row {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
}
.auth-code {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 22px;
- letter-spacing: 4px;
+ letter-spacing: 6px;
font-weight: 700;
text-align: center;
- padding: 12px;
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px dashed var(--border, rgba(255, 255, 255, 0.18));
+ padding: 14px 12px;
+ border-radius: 11px;
+ background: color-mix(in srgb, var(--panel-3) 70%, transparent);
+ border: 1px dashed var(--border);
+ color: var(--text);
}
.auth-hint {
- margin: 4px 0 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 2px 0 0;
font-size: 12px;
- color: var(--muted, rgba(255, 255, 255, 0.55));
+ color: var(--muted-2);
}
-.auth-error {
- background: rgba(248, 81, 73, 0.08);
- border: 1px solid rgba(248, 81, 73, 0.4);
- color: #f85149;
- padding: 12px;
+.auth-hint-center {
+ justify-content: center;
+}
+
+.auth-spinner {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
+ border-top-color: var(--accent);
+ animation: auth-spin 0.9s linear infinite;
+}
+
+@keyframes auth-spin {
+ to { transform: rotate(360deg); }
+}
+
+.auth-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.auth-field input {
+ padding: 11px 12px;
+ border: 1px solid var(--border-soft);
border-radius: 10px;
- margin-bottom: 16px;
+ background: var(--panel-2);
+ color: var(--text);
+ font-size: 13.5px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ letter-spacing: 0.02em;
+ text-transform: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+
+.auth-field input:focus-visible {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: var(--ring);
+}
+
+.auth-primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ padding: 11px 14px;
+ border-radius: 10px;
+ border: 1px solid color-mix(in srgb, var(--accent) 60%, black);
+ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 95%, white), var(--accent));
+ color: #06121c;
+ font-weight: 700;
+ font-size: 13.5px;
+ cursor: pointer;
+ margin-top: 2px;
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 28%, transparent);
+ transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
+}
+
+.auth-primary:hover {
+ filter: brightness(1.05);
+ transform: translateY(-1px);
+}
+
+.auth-primary:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.auth-secondary {
+ padding: 8px 12px;
+ border-radius: 9px;
+ border: 1px solid var(--border-soft);
+ background: var(--panel-2);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 12.5px;
+ font-weight: 600;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+
+.auth-secondary:hover {
+ border-color: var(--button-hover-border);
+ background: var(--panel-3);
+}
+
+.auth-back {
+ align-self: flex-start;
+ margin-top: 4px;
+ padding: 6px 10px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: color 120ms ease, background 120ms ease;
+}
+
+.auth-back:hover {
+ color: var(--text);
+ background: var(--panel-2);
+}
+
+.auth-error {
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--danger) 42%, transparent);
+ color: color-mix(in srgb, var(--danger) 90%, white);
+ padding: 12px 14px;
+ border-radius: 11px;
font-size: 13px;
- line-height: 1.5;
+ line-height: 1.55;
+}
+
+.auth-error strong {
+ display: block;
+ margin-bottom: 4px;
+ color: color-mix(in srgb, var(--danger) 95%, white);
+}
+
+.auth-error p {
+ margin: 0;
}
.auth-error code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- background: rgba(0, 0, 0, 0.25);
- padding: 1px 4px;
- border-radius: 4px;
+ background: rgba(0, 0, 0, 0.28);
+ padding: 1px 5px;
+ border-radius: 5px;
+ font-size: 12.5px;
}
.auth-error-line {
- margin: 12px 0 0;
- color: #f85149;
- font-size: 13px;
+ margin: 4px 0 0;
+ color: color-mix(in srgb, var(--danger) 92%, white);
+ font-size: 12.5px;
+}
+
+.auth-success {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ border-radius: 11px;
+ background: color-mix(in srgb, var(--success) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--success) 35%, transparent);
+}
+
+.auth-success .auth-status {
+ color: color-mix(in srgb, var(--success) 90%, white);
+ font-weight: 600;
+}
+
+.auth-success-check {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: var(--success);
+ color: #06121c;
+ display: grid;
+ place-items: center;
+ font-weight: 800;
+ font-size: 14px;
+ flex: 0 0 auto;
+}
+
+:root[data-theme="light"] .auth-primary {
+ color: #ffffff;
+}
+
+@media (max-width: 480px) {
+ .auth-card {
+ padding: 22px;
+ border-radius: 14px;
+ }
+ .auth-title {
+ font-size: 20px;
+ }
+ .auth-code {
+ font-size: 18px;
+ letter-spacing: 4px;
+ }
}
From 3a416a7863f9cd9ce4ba5eccb6887e7e16db55de Mon Sep 17 00:00:00 2001
From: Andrea Debernardi
Date: Fri, 15 May 2026 17:56:05 +0200
Subject: [PATCH 9/9] refactor(server): rename project to Gitdeck and migrate
data
Migrate legacy ~/.gh-issues-dashboard to ~/.gitdeck and update Docker,
README, i18n, tests
---
Dockerfile | 6 +++---
README.md | 33 ++++++++++++++-----------------
docker-compose.yml | 10 +++++-----
docs/translations.md | 2 +-
index.html | 2 +-
package.json | 4 ++--
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/server.ts | 2 +-
src/server/accountStore.ts | 29 +++++++++++++++++++++++----
src/server/authProvider.ts | 2 +-
src/server/config.ts | 3 ++-
src/server/githubClient.ts | 2 +-
tests/server/accountStore.test.ts | 7 +++++--
18 files changed, 68 insertions(+), 46 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 9d74397..69b407d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,12 +25,12 @@ COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/index.html ./index.html
-RUN mkdir -p /home/node/.gh-issues-dashboard \
- && chown -R node:node /home/node/.gh-issues-dashboard /app
+RUN mkdir -p /home/node/.gitdeck \
+ && chown -R node:node /home/node/.gitdeck /app
USER node
EXPOSE 8765
-VOLUME ["/home/node/.gh-issues-dashboard"]
+VOLUME ["/home/node/.gitdeck"]
CMD ["node", "dist/server.js"]
diff --git a/README.md b/README.md
index b600928..c0cd092 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,4 @@
-# gh-dashboard
-
-> `gh-dashboard` is a working name. The final project name will be picked together with the community — share your suggestion in the [naming discussion](https://github.com/debba/gh-dashboard/discussions/1) or on Discord.
+# Gitdeck
> The initial scaffolding of this repository was produced in an AI-assisted session with [Claude Code](https://claude.com/claude-code). From here on, code is reviewed and maintained by humans, and contributions are welcome.
@@ -12,12 +10,12 @@
-An open-source dashboard to explore your GitHub repositories, issues, pull requests, traffic, and CI activity from a single interface.
+An open-source, local dashboard to explore repositories, issues, pull requests, traffic, and CI activity across multiple accounts on GitHub and Forgejo-compatible forges (Codeberg, self-hosted) — from a single interface.
## Demo
-
+
## What it does
@@ -49,7 +47,7 @@ Open any repository to see:
The app is a single repository with two cooperating processes:
-- **Backend** — a Node HTTP server (`src/server.ts` + `src/server/*`) that handles GitHub OAuth (Device Flow), proxies all REST/GraphQL calls, caches responses on disk, and exposes a small JSON API under `/api/*`. The GitHub token is stored locally under `~/.gh-issues-dashboard/` and **never exposed to the browser**.
+- **Backend** — a Node HTTP server (`src/server.ts` + `src/server/*`) that handles GitHub OAuth (Device Flow), proxies all REST/GraphQL calls, caches responses on disk, and exposes a small JSON API under `/api/*`. The GitHub token is stored locally under `~/.gitdeck/` and **never exposed to the browser**.
- **Frontend** — a React 19 + Vite SPA (`src/main.tsx`, `src/App.tsx`, `src/components/*`, `src/api/*`) that consumes the backend's `/api/*` endpoints.
In production both are served by the Node process: Vite builds the SPA into `dist/client/` and the server falls back to `index.html` for non-API routes.
@@ -83,7 +81,7 @@ The dashboard talks to GitHub using a personal **OAuth App** with the **Device A
1. Go to → **OAuth Apps** → **New OAuth App**.
(For an org-owned app, use **Settings → Developer settings → OAuth Apps** on the organization instead.)
2. Fill in the form:
- - **Application name** — anything, e.g. `gh-dashboard (local)`.
+ - **Application name** — anything, e.g. `Gitdeck (local)`.
- **Homepage URL** — `http://127.0.0.1:8765` (or any URL you control; this is informational).
- **Authorization callback URL** — `http://127.0.0.1:8765` will do. Device Flow does not actually use a redirect, but GitHub requires the field.
3. Click **Register application**.
@@ -111,9 +109,9 @@ When you open the dashboard for the first time, it will:
1. call the backend, which asks GitHub for a **device code**;
2. show you a short **user code** and a verification URL (typically );
3. you paste the code on GitHub and approve the requested scopes;
-4. the backend exchanges the device code for an access token and stores it in `~/.gh-issues-dashboard/` — **the token never reaches the browser**.
+4. the backend exchanges the device code for an access token and stores it in `~/.gitdeck/` — **the token never reaches the browser**.
-Granted scopes default to `repo read:org project read:user user:email`. To narrow them, set `GITHUB_OAUTH_SCOPES` (see [Configuration](#configuration)). If you ever want to revoke access, remove the app from and delete the local token file under `~/.gh-issues-dashboard/`.
+Granted scopes default to `repo read:org project read:user user:email`. To narrow them, set `GITHUB_OAUTH_SCOPES` (see [Configuration](#configuration)). If you ever want to revoke access, remove the app from and delete the local token file under `~/.gitdeck/`.
## Configuration
@@ -134,13 +132,13 @@ The server reads its configuration from environment variables:
The dashboard can obtain a GitHub token in three different ways. Pick the one that fits your setup:
-- **`device` (default)** — OAuth App + Device Flow, as described above. The token is stored under `~/.gh-issues-dashboard/` and refreshed via the in-app sign-in screen. Requires `GITHUB_CLIENT_ID`.
+- **`device` (default)** — OAuth App + Device Flow, as described above. The token is stored under `~/.gitdeck/` and refreshed via the in-app sign-in screen. Requires `GITHUB_CLIENT_ID`.
- **`gh-cli`** — if you already use the [GitHub CLI](https://cli.github.com/), set `GH_AUTH_MODE=gh-cli` and the server will read the token by running `gh auth token` on each request (cached in-process for 60s). No OAuth App is needed; the scopes are whatever your `gh` session already has. Run `gh auth refresh -h github.com -s repo,read:org,project` if you need extra scopes.
- **`token`** — bring-your-own personal access token. Set `GH_AUTH_MODE=token` and export `GITHUB_TOKEN=`. Useful for headless / CI-style deployments.
In `gh-cli` and `token` modes the device-flow sign-in screen is hidden; the server treats the configured source as authoritative.
-Tokens and snapshots are persisted under `~/.gh-issues-dashboard/`.
+Tokens and snapshots are persisted under `~/.gitdeck/`. If you previously ran an older build that stored data in `~/.gh-issues-dashboard/`, the server migrates it automatically on first start.
### Quick env setup
@@ -196,7 +194,7 @@ Then open .
## Run with Docker
-A multi-stage `Dockerfile` and a `docker-compose.yml` are provided. The image builds the server + SPA bundle and runs as a non-root user; tokens and snapshots are persisted to a named volume mounted at `/home/node/.gh-issues-dashboard`.
+A multi-stage `Dockerfile` and a `docker-compose.yml` are provided. The image builds the server + SPA bundle and runs as a non-root user; tokens and snapshots are persisted to a named volume mounted at `/home/node/.gitdeck`.
With Docker Compose (recommended):
@@ -216,16 +214,16 @@ docker compose up -d --build
With plain Docker:
```bash
-docker build -t gh-dashboard .
-docker run -d --name gh-dashboard \
+docker build -t gitdeck .
+docker run -d --name gitdeck \
-p 8765:8765 \
-e GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx \
-e OPENAI_API_KEY=sk-... \
- -v gh-dashboard-data:/home/node/.gh-issues-dashboard \
- gh-dashboard
+ -v gitdeck-data:/home/node/.gitdeck \
+ gitdeck
```
-The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES`, `OPENAI_API_KEY` and `OPENAI_DIGEST_MODEL` from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gh-dashboard-data`.
+The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES`, `OPENAI_API_KEY` and `OPENAI_DIGEST_MODEL` from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gitdeck-data`.
## Test & type-check
@@ -266,7 +264,6 @@ Early scaffolding. APIs, modules, and the UI are still being shaped — expect r
## Community
- [Discord server](https://discord.gg/YrZPHAwMSG) — suggest features, report issues, or just say hi.
-- [Help name the project](https://github.com/debba/gh-dashboard/discussions/1) — open discussion for naming suggestions.
## Contributing
diff --git a/docker-compose.yml b/docker-compose.yml
index 58cb145..4185829 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,8 @@
services:
- gh-dashboard:
+ gitdeck:
build: .
- image: gh-dashboard:latest
- container_name: gh-dashboard
+ image: gitdeck:latest
+ container_name: gitdeck
restart: unless-stopped
ports:
- "8765:8765"
@@ -12,7 +12,7 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_DIGEST_MODEL: ${OPENAI_DIGEST_MODEL:-}
volumes:
- - gh-dashboard-data:/home/node/.gh-issues-dashboard
+ - gitdeck-data:/home/node/.gitdeck
volumes:
- gh-dashboard-data:
+ gitdeck-data:
diff --git a/docs/translations.md b/docs/translations.md
index d9a3a9e..a2d69fd 100644
--- a/docs/translations.md
+++ b/docs/translations.md
@@ -73,7 +73,7 @@ Use a lowercase language code as the file name. For example, Portuguese would us
import type { en } from "./en";
export const pt: Record = {
- "app.title": "GitHub Dashboard",
+ "app.title": "Gitdeck",
// ...
};
```
diff --git a/index.html b/index.html
index d890d23..1da5dfa 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,7 @@
- Git Dashboard
+ Gitdeck
diff --git a/package.json b/package.json
index 127003a..bdfd427 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,9 @@
{
- "name": "gh-issues-dashboard",
+ "name": "gitdeck",
"version": "1.0.2",
"private": true,
"type": "module",
- "description": "Local dashboard showing open issues across your GitHub repositories using GitHub OAuth.",
+ "description": "Local multi-account dashboard for GitHub and Forgejo-compatible forges (Codeberg, self-hosted).",
"scripts": {
"api": "tsx watch src/server.ts",
"dev": "concurrently \"npm:api\" \"vite --host 127.0.0.1\"",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 88e6a70..b19702d 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const de: Record = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "Sprache",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 00ddf6d..cf7afd0 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -1,5 +1,5 @@
export const en = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "Language",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 419025f..06545a2 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const es: Record = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "Idioma",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 2545e6f..f4d5c45 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const fr: Record = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "Langue",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 1dbceff..b4e1932 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const it: Record = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "Lingua",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 68d7700..e92c97e 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -1,7 +1,7 @@
import type { en } from "./en";
export const zh: Record = {
- "app.title": "Git Dashboard",
+ "app.title": "Gitdeck",
"language.label": "语言",
"language.en": "English",
"language.it": "Italiano",
diff --git a/src/server.ts b/src/server.ts
index 10db89b..d6715f2 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -366,7 +366,7 @@ async function handleDependents(res: ServerResponse, u: URL): Promise {
const token = await getToken().catch(() => "");
const resp = await fetch(pageUrl, {
headers: {
- "User-Agent": "gh-dashboard/1.0 (+local)",
+ "User-Agent": "gitdeck/1.0 (+local)",
"Accept": "text/html",
...(token ? { "Authorization": `Bearer ${token}` } : {}),
},
diff --git a/src/server/accountStore.ts b/src/server/accountStore.ts
index 8070700..c1b8978 100644
--- a/src/server/accountStore.ts
+++ b/src/server/accountStore.ts
@@ -1,6 +1,6 @@
-import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
-import { DATA_DIR } from "./config";
+import { DATA_DIR, LEGACY_DATA_DIR } from "./config";
import type { Account, AccountStoreData, ProviderConfig } from "./providers/types";
const ACCOUNTS_PATH = resolve(DATA_DIR, "accounts.json");
@@ -20,7 +20,7 @@ const DEFAULT_PROVIDER_CONFIGS: Record = {
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",
+ userAgent: "gitdeck",
},
"codeberg.org": {
id: "codeberg.org",
@@ -31,7 +31,7 @@ const DEFAULT_PROVIDER_CONFIGS: Record = {
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",
+ userAgent: "gitdeck",
},
};
@@ -126,7 +126,28 @@ async function backupLegacy(): Promise {
}
}
+async function migrateLegacyDataDir(): Promise {
+ if (DATA_DIR === LEGACY_DATA_DIR) return;
+ try {
+ await access(DATA_DIR);
+ return;
+ } catch {
+ // new dir absent — check legacy
+ }
+ try {
+ await access(LEGACY_DATA_DIR);
+ } catch {
+ return;
+ }
+ try {
+ await rename(LEGACY_DATA_DIR, DATA_DIR);
+ } catch {
+ // best-effort: leave legacy in place if rename fails
+ }
+}
+
async function doInit(): Promise {
+ await migrateLegacyDataDir();
const existing = await readAccountsFile();
if (existing) {
state = { persisted: existing, ephemeral: [] };
diff --git a/src/server/authProvider.ts b/src/server/authProvider.ts
index 65155e6..aff69d1 100644
--- a/src/server/authProvider.ts
+++ b/src/server/authProvider.ts
@@ -45,7 +45,7 @@ async function fetchLogin(token: string): Promise<{ login: string | null; scope:
const response = await fetch(USER_URL, {
headers: {
Accept: "application/vnd.github+json",
- "User-Agent": "gh-issues-dashboard",
+ "User-Agent": "gitdeck",
Authorization: `Bearer ${token}`,
},
});
diff --git a/src/server/config.ts b/src/server/config.ts
index dda3141..0fc60b4 100644
--- a/src/server/config.ts
+++ b/src/server/config.ts
@@ -11,7 +11,8 @@ export const CLIENT_DIR = resolve(PROJECT_ROOT, "dist", "client");
export const CLIENT_INDEX_PATH = resolve(CLIENT_DIR, "index.html");
export const SOURCE_INDEX_PATH = resolve(PROJECT_ROOT, "index.html");
-export const DATA_DIR = resolve(homedir(), ".gh-issues-dashboard");
+export const DATA_DIR = resolve(homedir(), ".gitdeck");
+export const LEGACY_DATA_DIR = resolve(homedir(), ".gh-issues-dashboard");
export const SNAPSHOTS_PATH = resolve(DATA_DIR, "snapshots.json");
export const DIGESTS_PATH = resolve(DATA_DIR, "daily-digests.json");
diff --git a/src/server/githubClient.ts b/src/server/githubClient.ts
index e5d87ce..4a81894 100644
--- a/src/server/githubClient.ts
+++ b/src/server/githubClient.ts
@@ -2,7 +2,7 @@ import { getActiveToken } from "./authProvider";
const API_ROOT = "https://api.github.com";
const GRAPHQL_URL = `${API_ROOT}/graphql`;
-const USER_AGENT = "gh-issues-dashboard";
+const USER_AGENT = "gitdeck";
export class AuthRequiredError extends Error {
constructor(message = "authentication required") {
diff --git a/tests/server/accountStore.test.ts b/tests/server/accountStore.test.ts
index 5bec2fd..335e5f9 100644
--- a/tests/server/accountStore.test.ts
+++ b/tests/server/accountStore.test.ts
@@ -2,16 +2,19 @@ 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 { TMP_DIR, LEGACY_TMP_DIR } = vi.hoisted(() => {
const { tmpdir } = require("node:os") as typeof import("node:os");
const { resolve } = require("node:path") as typeof import("node:path");
+ const base = resolve(tmpdir(), `gh-dash-accountstore-${process.pid}-${Date.now()}`);
return {
- TMP_DIR: resolve(tmpdir(), `gh-dash-accountstore-${process.pid}-${Date.now()}`),
+ TMP_DIR: base,
+ LEGACY_TMP_DIR: `${base}-legacy`,
};
});
vi.mock("../../src/server/config", () => ({
DATA_DIR: TMP_DIR,
+ LEGACY_DATA_DIR: LEGACY_TMP_DIR,
}));
const store = await import("../../src/server/accountStore");