mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 18:06:30 +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
|
|
@ -5,8 +5,6 @@ import os from 'node:os';
|
|||
import {
|
||||
connectProvider,
|
||||
disconnectProvider,
|
||||
isConnected,
|
||||
getConnectedProviders,
|
||||
listProviders,
|
||||
} from './oauth-handler.js';
|
||||
import { watcher as watcherCore, workspace } from '@x/core';
|
||||
|
|
@ -24,6 +22,7 @@ import container from '@x/core/dist/di/container.js';
|
|||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection } from '@x/core/dist/models/models.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
|
|
@ -364,19 +363,18 @@ export function setupIpcHandlers() {
|
|||
return { success: true };
|
||||
},
|
||||
'oauth:connect': async (_event, args) => {
|
||||
return await connectProvider(args.provider, args.clientId);
|
||||
return await connectProvider(args.provider, args.clientId?.trim());
|
||||
},
|
||||
'oauth:disconnect': async (_event, args) => {
|
||||
return await disconnectProvider(args.provider);
|
||||
},
|
||||
'oauth:is-connected': async (_event, args) => {
|
||||
return await isConnected(args.provider);
|
||||
},
|
||||
'oauth:list-providers': async () => {
|
||||
return listProviders();
|
||||
},
|
||||
'oauth:get-connected-providers': async () => {
|
||||
return await getConnectedProviders();
|
||||
'oauth:getState': async () => {
|
||||
const repo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const config = await repo.getClientFacingConfig();
|
||||
return { config };
|
||||
},
|
||||
'granola:getConfig': async () => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ import { createAuthServer } from './auth-server.js';
|
|||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||
import {
|
||||
clearProviderClientIdOverride,
|
||||
getProviderClientIdOverride,
|
||||
hasProviderClientIdOverride,
|
||||
setProviderClientIdOverride,
|
||||
} from '@x/core/dist/auth/provider-client-id.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
|
||||
|
|
@ -80,16 +74,20 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
/**
|
||||
* Get or create OAuth configuration for a provider
|
||||
*/
|
||||
async function getProviderConfiguration(provider: string): Promise<Configuration> {
|
||||
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
|
||||
const config = getProviderConfig(provider);
|
||||
const resolveClientId = (): string => {
|
||||
const override = getProviderClientIdOverride(provider);
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const resolveClientId = async (): Promise<string> => {
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
return config.client.clientId;
|
||||
}
|
||||
if (clientIdOverride) {
|
||||
return clientIdOverride;
|
||||
}
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const clientId = await oauthRepo.getClientId(provider);
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
|
||||
};
|
||||
|
||||
|
|
@ -97,7 +95,7 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
|
|||
if (config.client.mode === 'static') {
|
||||
// Discover endpoints, use static client ID
|
||||
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
|
||||
const clientId = resolveClientId();
|
||||
const clientId = await resolveClientId();
|
||||
return await oauthClient.discoverConfiguration(
|
||||
config.discovery.issuer,
|
||||
clientId
|
||||
|
|
@ -137,7 +135,7 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
|
|||
}
|
||||
|
||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||
const clientId = resolveClientId();
|
||||
const clientId = await resolveClientId();
|
||||
return oauthClient.createStaticConfiguration(
|
||||
config.discovery.authorizationEndpoint,
|
||||
config.discovery.tokenEndpoint,
|
||||
|
|
@ -161,15 +159,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
const providerConfig = getProviderConfig(provider);
|
||||
|
||||
if (provider === 'google') {
|
||||
const trimmedClientId = clientId?.trim();
|
||||
if (!trimmedClientId) {
|
||||
if (!clientId) {
|
||||
return { success: false, error: 'Google client ID is required to connect.' };
|
||||
}
|
||||
setProviderClientIdOverride(provider, trimmedClientId);
|
||||
}
|
||||
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider);
|
||||
const config = await getProviderConfiguration(provider, clientId);
|
||||
|
||||
// Generate PKCE codes
|
||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||
|
|
@ -217,6 +213,10 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
// Save tokens
|
||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||
await oauthRepo.saveTokens(provider, tokens);
|
||||
if (provider === 'google' && clientId) {
|
||||
await oauthRepo.setClientId(provider, clientId);
|
||||
}
|
||||
await oauthRepo.clearError(provider);
|
||||
|
||||
// Trigger immediate sync for relevant providers
|
||||
if (provider === 'google') {
|
||||
|
|
@ -282,9 +282,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
await oauthRepo.clearTokens(provider);
|
||||
if (provider === 'google') {
|
||||
clearProviderClientIdOverride(provider);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('OAuth disconnect failed:', error);
|
||||
|
|
@ -292,23 +289,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is connected
|
||||
*/
|
||||
export async function isConnected(provider: string): Promise<{ isConnected: boolean }> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
if (provider === 'google' && !hasProviderClientIdOverride(provider)) {
|
||||
return { isConnected: false };
|
||||
}
|
||||
const connected = await oauthRepo.isConnected(provider);
|
||||
return { isConnected: connected };
|
||||
} catch (error) {
|
||||
console.error('OAuth connection check failed:', error);
|
||||
return { isConnected: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a provider (internal use only)
|
||||
* Refreshes token if expired
|
||||
|
|
@ -326,6 +306,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
|||
if (oauthClient.isTokenExpired(tokens)) {
|
||||
if (!tokens.refresh_token) {
|
||||
// No refresh token, need to reconnect
|
||||
await oauthRepo.setError(provider, 'Missing refresh token. Please reconnect.');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -338,6 +319,8 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
|||
tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
|
||||
await oauthRepo.saveTokens(provider, tokens);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Token refresh failed';
|
||||
await oauthRepo.setError(provider, message);
|
||||
console.error('Token refresh failed:', error);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -350,23 +333,6 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of connected providers
|
||||
*/
|
||||
export async function getConnectedProviders(): Promise<{ providers: string[] }> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const providers = await oauthRepo.getConnectedProviders();
|
||||
const filteredProviders = providers.filter((provider) =>
|
||||
provider === 'google' ? hasProviderClientIdOverride(provider) : true
|
||||
);
|
||||
return { providers: filteredProviders };
|
||||
} catch (error) {
|
||||
console.error('Get connected providers failed:', error);
|
||||
return { providers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available providers
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"motion": "^12.23.26",
|
||||
"nanoid": "^5.1.6",
|
||||
"posthog-js": "^1.332.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
|
||||
import { AlertTriangle, Loader2, Mic, Mail, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -28,17 +28,28 @@ interface ProviderState {
|
|||
isConnecting: boolean
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ConnectorsPopoverProps {
|
||||
children: React.ReactNode
|
||||
tooltip?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) {
|
||||
const [openInternal, setOpenInternal] = useState(false)
|
||||
const isControlled = typeof openProp === "boolean"
|
||||
const open = isControlled ? openProp : openInternal
|
||||
const setOpen = onOpenChange ?? setOpenInternal
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(undefined)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
|
|
@ -184,25 +195,35 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
|
||||
const newStates: Record<string, ProviderState> = {}
|
||||
|
||||
await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:is-connected', { provider })
|
||||
newStates[provider] = {
|
||||
isConnected: result.isConnected,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check connection status for ${provider}:`, error)
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
const statusMap: Record<string, ProviderStatus> = {}
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerConfig = config[provider]
|
||||
newStates[provider] = {
|
||||
isConnected: providerConfig?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
})
|
||||
)
|
||||
if (providerConfig?.error) {
|
||||
statusMap[provider] = { error: providerConfig.error }
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStatus(statusMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection statuses:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
setProviderStatus({})
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
|
|
@ -302,6 +323,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
const existingClientId = getGoogleClientId()
|
||||
if (!existingClientId) {
|
||||
setGoogleClientIdOpen(true)
|
||||
|
|
@ -317,6 +339,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
setGoogleClientIdOpen(false)
|
||||
setGoogleClientIdDescription(undefined)
|
||||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
|
|
@ -361,6 +384,10 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
}
|
||||
}, [])
|
||||
|
||||
const hasProviderError = Object.values(providerStatus).some(
|
||||
(status) => Boolean(status?.error)
|
||||
)
|
||||
|
||||
// Helper to render an OAuth provider row
|
||||
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
|
||||
const state = providerStates[provider] || {
|
||||
|
|
@ -368,6 +395,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
isLoading: true,
|
||||
isConnecting: false,
|
||||
}
|
||||
const needsReconnect = Boolean(providerStatus[provider]?.error)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -382,6 +410,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{state.isLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : needsReconnect ? (
|
||||
<span className="text-xs text-amber-600">Needs reconnect</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">{description}</span>
|
||||
)}
|
||||
|
|
@ -390,6 +420,24 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
<div className="shrink-0">
|
||||
{state.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : needsReconnect ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (provider === 'google') {
|
||||
setGoogleClientIdDescription(
|
||||
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
|
||||
)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
startConnect(provider)
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : state.isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -423,9 +471,15 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
<>
|
||||
<GoogleClientIdModal
|
||||
open={googleClientIdOpen}
|
||||
onOpenChange={setGoogleClientIdOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setGoogleClientIdOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setGoogleClientIdDescription(undefined)
|
||||
}
|
||||
}}
|
||||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
description={googleClientIdDescription}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{tooltip ? (
|
||||
|
|
@ -451,7 +505,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
className="w-80 p-0"
|
||||
>
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="font-semibold text-sm">Connectors</h4>
|
||||
<h4 className="font-semibold text-sm flex items-center gap-1.5">
|
||||
Connected accounts
|
||||
{hasProviderError && (
|
||||
<AlertTriangle className="size-3 text-amber-500/80 animate-pulse" />
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Connect accounts to sync data
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface GoogleClientIdModalProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (clientId: string) => void
|
||||
isSubmitting?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function GoogleClientIdModal({
|
||||
|
|
@ -26,6 +27,7 @@ export function GoogleClientIdModal({
|
|||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
description,
|
||||
}: GoogleClientIdModalProps) {
|
||||
const [clientId, setClientId] = useState("")
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ export function GoogleClientIdModal({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Enter Google Client ID</DialogTitle>
|
||||
<DialogDescription>
|
||||
This app does not store the client ID. You will be prompted each session.
|
||||
{description ?? "Enter the client ID for your Google OAuth app to continue."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -325,25 +325,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
|
||||
const newStates: Record<string, ProviderState> = {}
|
||||
|
||||
await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:is-connected', { provider })
|
||||
newStates[provider] = {
|
||||
isConnected: result.isConnected,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check connection status for ${provider}:`, error)
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: config[provider]?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status for providers:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Copy,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
Mic,
|
||||
Network,
|
||||
|
|
@ -34,6 +35,17 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -379,6 +391,45 @@ export function SidebarContentPanel({
|
|||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
const [hasOauthError, setHasOauthError] = useState(false)
|
||||
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
||||
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
||||
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
|
||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const refreshOauthError = async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
const hasError = Object.values(config).some((entry) => Boolean(entry?.error))
|
||||
if (mounted) {
|
||||
setHasOauthError(hasError)
|
||||
if (!hasError) {
|
||||
setShowOauthAlert(true)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OAuth state:', error)
|
||||
if (mounted) {
|
||||
setHasOauthError(false)
|
||||
setShowOauthAlert(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshOauthError()
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
refreshOauthError()
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" {...props}>
|
||||
|
|
@ -430,12 +481,69 @@ export function SidebarContentPanel({
|
|||
{/* Bottom actions */}
|
||||
<div className="border-t border-sidebar-border px-2 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ConnectorsPopover>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
|
||||
<Plug className="size-4" />
|
||||
<span>Connectors</span>
|
||||
</button>
|
||||
</ConnectorsPopover>
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen}>
|
||||
<button
|
||||
ref={connectorsButtonRef}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<Plug className="size-4" />
|
||||
<span>Connected accounts</span>
|
||||
</button>
|
||||
</ConnectorsPopover>
|
||||
{hasOauthError && (
|
||||
<AlertDialog
|
||||
open={showOauthAlert}
|
||||
onOpenChange={setShowOauthAlert}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center"
|
||||
aria-label="OAuth connection issues"
|
||||
>
|
||||
<AlertTriangle className="size-3 text-amber-500/90 animate-pulse" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
if (openConnectorsAfterClose) {
|
||||
setOpenConnectorsAfterClose(false)
|
||||
setConnectorsOpen(true)
|
||||
}
|
||||
connectorsButtonRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reconnect your accounts</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
One or more connected accounts need attention. Open Connected accounts
|
||||
to review the status and reconnect if needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setOpenConnectorsAfterClose(false)
|
||||
setShowOauthAlert(false)
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setOpenConnectorsAfterClose(true)
|
||||
setShowOauthAlert(false)
|
||||
}}
|
||||
>
|
||||
View connected accounts
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDialog>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
|
||||
<Settings className="size-4" />
|
||||
|
|
|
|||
194
apps/x/apps/renderer/src/components/ui/alert-dialog.tsx
Normal file
194
apps/x/apps/renderer/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -22,9 +22,11 @@ const buttonVariants = cva(
|
|||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
|
|
@ -46,7 +48,7 @@ function Button({
|
|||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ export function useOAuth(provider: string) {
|
|||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:is-connected', { provider });
|
||||
setIsConnected(result.isConnected);
|
||||
const result = await window.ipc.invoke('oauth:getState', null);
|
||||
const config = result.config || {};
|
||||
setIsConnected(config[provider]?.connected ?? false);
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status:', error);
|
||||
setIsConnected(false);
|
||||
|
|
@ -107,8 +108,12 @@ export function useConnectedProviders() {
|
|||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:get-connected-providers', null);
|
||||
setProviders(result.providers);
|
||||
const result = await window.ipc.invoke('oauth:getState', null);
|
||||
const config = result.config || {};
|
||||
const connected = Object.entries(config)
|
||||
.filter(([, value]) => value?.connected)
|
||||
.map(([key]) => key);
|
||||
setProviders(connected);
|
||||
} catch (error) {
|
||||
console.error('Failed to get connected providers:', error);
|
||||
setProviders([]);
|
||||
|
|
@ -149,4 +154,3 @@ export function useAvailableProviders() {
|
|||
|
||||
return { providers, isLoading };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue