oauth: persist client state, simplify IPC, and refactor

connected-accounts UI

This refactor simplifies OAuth storage/IPC and updates the Electron UI
to use the new client-facing contract. OAuth state is now persisted per
provider with tokens, optional clientId, and an error string. A new oauth:getState
IPC returns only client-facing state (connected + error), and the UI renders
error/reconnect flow based on that.

  Core changes
  - Replace OAuth config with providers { tokens, clientId?, error? }
    and add zod-based migration from legacy token maps.
  - Persist Google clientId after successful OAuth and keep error state
    in repo.
  - Surface provider errors from refresh/credential failures in Google +
    Fireflies.
  - Add oauth:getState in IPC, returning client-facing config; remove
    old status wiring in the UI.

  UI changes
  - Switch renderer status checks to oauth:getState and derive connected/error
    from config.
  - Add alert dialog for account issues and update copy to “Connected
    accounts”.
  - Provide “View connected accounts” CTA that opens the Connectors popover.
  - Add shadcn alert-dialog component and Radix dependency.

  Notes
  - Adds @radix-ui/react-alert-dialog and shadcn wrapper.
  - pnpm-lock updated accordingly.
This commit is contained in:
Ramnique Singh 2026-02-17 09:54:34 +05:30
parent 492b59e2e8
commit 9d4f25895e
15 changed files with 1292 additions and 206 deletions

View file

@ -2,19 +2,42 @@ import { WorkDir } from '../config/config.js';
import fs from 'fs/promises';
import path from 'path';
import { OAuthTokens } from './types.js';
import z from 'zod';
const ProviderConnectionSchema = z.object({
tokens: OAuthTokens,
clientId: z.string().optional(),
error: z.string().optional(),
});
const OAuthConfigSchema = z.object({
version: z.number().optional(),
providers: z.record(z.string(), ProviderConnectionSchema),
});
const ClientFacingConfigSchema = z.record(z.string(), z.object({
connected: z.boolean(),
error: z.string().optional(),
}));
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
const DEFAULT_CONFIG: z.infer<typeof OAuthConfigSchema> = {
version: 2,
providers: {},
};
export interface IOAuthRepo {
getTokens(provider: string): Promise<OAuthTokens | null>;
saveTokens(provider: string, tokens: OAuthTokens): Promise<void>;
clearTokens(provider: string): Promise<void>;
isConnected(provider: string): Promise<boolean>;
getConnectedProviders(): Promise<string[]>;
getClientId(provider: string): Promise<string | null>;
setClientId(provider: string, clientId: string): Promise<void>;
setError(provider: string, errorMessage: string): Promise<void>;
clearError(provider: string): Promise<void>;
getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>>;
}
type OAuthStorage = {
[provider: string]: OAuthTokens;
};
export class FSOAuthRepo implements IOAuthRepo {
private readonly configPath = path.join(WorkDir, 'config', 'oauth.json');
@ -26,76 +49,123 @@ export class FSOAuthRepo implements IOAuthRepo {
try {
await fs.access(this.configPath);
} catch {
// File doesn't exist, create it with empty object
await fs.writeFile(this.configPath, JSON.stringify({}, null, 2));
await fs.writeFile(this.configPath, JSON.stringify(DEFAULT_CONFIG, null, 2));
}
}
private async readConfig(): Promise<OAuthStorage> {
private normalizeConfig(payload: unknown): { config: z.infer<typeof OAuthConfigSchema>; migrated: boolean } {
// check if payload conforms to updated schema
const result = OAuthConfigSchema.safeParse(payload);
if (result.success) {
return { config: result.data, migrated: false };
}
// otherwise attempt to parse as legacy schema
const legacyConfig = LegacyOauthConfigSchema.parse(payload);
const updatedConfig: z.infer<typeof OAuthConfigSchema> = {
version: 2,
providers: {},
};
for (const [provider, tokens] of Object.entries(legacyConfig)) {
updatedConfig.providers[provider] = {
tokens,
};
}
return { config: updatedConfig, migrated: true };
}
private async readConfig(): Promise<z.infer<typeof OAuthConfigSchema>> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
const parsed = JSON.parse(content);
return parsed as OAuthStorage;
const { config, migrated } = this.normalizeConfig(parsed);
if (migrated) {
await this.writeConfig(config);
}
return config;
} catch {
return {};
return { ...DEFAULT_CONFIG };
}
}
private async writeConfig(config: OAuthStorage): Promise<void> {
private async writeConfig(config: z.infer<typeof OAuthConfigSchema>): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
async getTokens(provider: string): Promise<OAuthTokens | null> {
const config = await this.readConfig();
const tokens = config[provider];
if (!tokens) {
return null;
}
// Validate tokens structure
try {
return OAuthTokens.parse(tokens);
} catch {
// Invalid tokens, remove them
await this.clearTokens(provider);
return null;
}
const tokens = config.providers[provider]?.tokens;
return tokens ?? null;
}
async saveTokens(provider: string, tokens: OAuthTokens): Promise<void> {
const config = await this.readConfig();
config[provider] = tokens;
if (config.providers[provider]) {
delete config.providers[provider];
}
config.providers[provider] = {
tokens,
};
await this.writeConfig(config);
}
async clearTokens(provider: string): Promise<void> {
const config = await this.readConfig();
delete config[provider];
delete config.providers[provider];
await this.writeConfig(config);
}
async isConnected(provider: string): Promise<boolean> {
const tokens = await this.getTokens(provider);
if (!tokens) {
return false;
}
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at > now;
}
async getConnectedProviders(): Promise<string[]> {
async getClientId(provider: string): Promise<string | null> {
const config = await this.readConfig();
const connected: string[] = [];
for (const provider of Object.keys(config)) {
if (await this.isConnected(provider)) {
connected.push(provider);
}
}
return connected;
const clientId = config.providers[provider]?.clientId;
return clientId ?? null;
}
}
async setClientId(provider: string, clientId: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
config.providers[provider].clientId = clientId;
await this.writeConfig(config);
}
async clearClientId(provider: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
delete config.providers[provider].clientId;
await this.writeConfig(config);
}
async setError(provider: string, errorMessage: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
config.providers[provider].error = errorMessage;
await this.writeConfig(config);
}
async clearError(provider: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
delete config.providers[provider].error;
await this.writeConfig(config);
}
async getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>> {
const config = await this.readConfig();
const clientFacingConfig: z.infer<typeof ClientFacingConfigSchema> = {};
for (const [provider, providerConfig] of Object.entries(config.providers)) {
clientFacingConfig[provider] = {
connected: !!providerConfig.tokens,
error: providerConfig.error,
};
}
return clientFacingConfig;
}
}

View file

@ -49,6 +49,7 @@ export class FirefliesClientFactory {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("[Fireflies] Token expired and no refresh token available.");
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
this.clearCache();
return null;
}
@ -75,6 +76,8 @@ export class FirefliesClientFactory {
console.log(`[Fireflies] Token refreshed successfully`);
return this.cache.client;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies';
await oauthRepo.setError(this.PROVIDER_NAME, message);
console.error("[Fireflies] Failed to refresh token:", error);
this.clearCache();
return null;
@ -104,12 +107,6 @@ export class FirefliesClientFactory {
*/
static async hasValidCredentials(): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const isConnected = await oauthRepo.isConnected(this.PROVIDER_NAME);
if (!isConnected) {
return false;
}
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
return tokens !== null;
}
@ -215,4 +212,3 @@ export class FirefliesClientFactory {
return client;
}
}

View file

@ -3,7 +3,6 @@ import container from '../di/container.js';
import { IOAuthRepo } from '../auth/repo.js';
import { IClientRegistrationRepo } from '../auth/client-repo.js';
import { getProviderConfig } from '../auth/providers.js';
import { getProviderClientIdOverride } from '../auth/provider-client-id.js';
import * as oauthClient from '../auth/oauth-client.js';
import type { Configuration } from '../auth/oauth-client.js';
import { OAuthTokens } from '../auth/types.js';
@ -26,12 +25,14 @@ export class GoogleClientFactory {
clientId: null,
};
private static resolveClientId(): string {
const override = getProviderClientIdOverride(this.PROVIDER_NAME);
if (!override) {
throw new Error('Google client ID not provided for this session.');
private static async resolveClientId(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const clientId = await oauthRepo.getClientId(this.PROVIDER_NAME);
if (!clientId) {
await oauthRepo.setError(this.PROVIDER_NAME, 'Google client ID missing. Please reconnect.');
throw new Error('Google client ID missing. Please reconnect.');
}
return override;
return clientId;
}
/**
@ -63,6 +64,7 @@ export class GoogleClientFactory {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("[OAuth] Token expired and no refresh token available for Google.");
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
this.clearCache();
return null;
}
@ -79,10 +81,15 @@ export class GoogleClientFactory {
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
this.cache.client = this.createClientFromTokens(refreshedTokens);
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
}
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId);
console.log(`[OAuth] Token refreshed successfully`);
return this.cache.client;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
await oauthRepo.setError(this.PROVIDER_NAME, message);
console.error("[OAuth] Failed to refresh token for Google:", error);
this.clearCache();
return null;
@ -97,7 +104,10 @@ export class GoogleClientFactory {
// Create new client with current tokens
console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens;
this.cache.client = this.createClientFromTokens(tokens);
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
}
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId);
return this.cache.client;
}
@ -106,16 +116,6 @@ export class GoogleClientFactory {
*/
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const isConnected = await oauthRepo.isConnected(this.PROVIDER_NAME);
if (!isConnected) {
return false;
}
if (!getProviderClientIdOverride(this.PROVIDER_NAME)) {
return false;
}
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
if (!tokens) {
return false;
@ -144,7 +144,7 @@ export class GoogleClientFactory {
* Initialize cached configuration (called once)
*/
private static async initializeConfigCache(): Promise<void> {
const clientId = this.resolveClientId();
const clientId = await this.resolveClientId();
if (this.cache.config && this.cache.clientId === clientId) {
return; // Already initialized for this client ID
@ -202,9 +202,7 @@ export class GoogleClientFactory {
/**
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client {
const clientId = this.resolveClientId();
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
const client = new OAuth2Client(
clientId,