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

@ -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');

View file

@ -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
*/

View file

@ -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",

View file

@ -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>

View file

@ -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">

View file

@ -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])

View file

@ -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" />

View 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,
}

View file

@ -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

View file

@ -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 };
}