mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 08:56:22 +02:00
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:
parent
492b59e2e8
commit
9d4f25895e
15 changed files with 1292 additions and 206 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue