From 84e0a7fe81e627d798dce8704f811825c41aad08 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Tue, 25 Nov 2025 07:59:20 +0100 Subject: [PATCH] Add OAuth flows and UI integration - Implemented OAuth endpoints (providers list, authorization, callback, linked accounts management). - Added UI translations for OAuth workflows (auth process messages, linked accounts management). - Extended TypeScript types and React hooks to support OAuth features. - Updated app configuration with OAuth-specific settings and provider details. - Introduced skeleton implementations for authorization and token endpoints in provider mode. - Included unit test and integration hooks for OAuth capabilities. --- frontend/messages/en.json | 23 + frontend/messages/it.json | 23 + .../(auth)/auth/callback/[provider]/page.tsx | 107 +++ .../settings/accounts/page.tsx | 24 + frontend/src/components/auth/LoginForm.tsx | 7 + frontend/src/components/auth/OAuthButtons.tsx | 158 ++++ frontend/src/components/auth/RegisterForm.tsx | 13 +- .../settings/LinkedAccountsSettings.tsx | 195 +++++ frontend/src/components/settings/index.ts | 1 + frontend/src/config/app.config.ts | 18 + frontend/src/lib/api/generated/sdk.gen.ts | 236 +++++- frontend/src/lib/api/generated/types.gen.ts | 672 ++++++++++++++++++ frontend/src/lib/api/hooks/useOAuth.ts | 235 ++++++ frontend/src/mocks/handlers/generated.ts | 3 +- 14 files changed, 1711 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/[locale]/(auth)/auth/callback/[provider]/page.tsx create mode 100644 frontend/src/app/[locale]/(authenticated)/settings/accounts/page.tsx create mode 100644 frontend/src/components/auth/OAuthButtons.tsx create mode 100644 frontend/src/components/settings/LinkedAccountsSettings.tsx create mode 100644 frontend/src/lib/api/hooks/useOAuth.ts diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 94c09b2..f7035bf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -143,6 +143,18 @@ "hasNumber": "Contains a number", "hasUppercase": "Contains an uppercase letter" } + }, + "oauth": { + "divider": "Or continue with", + "loading": "Loading providers...", + "continueWith": "Continue with {provider}", + "signUpWith": "Sign up with {provider}", + "processing": "Completing authentication...", + "authFailed": "Authentication Failed", + "providerError": "The authentication provider returned an error", + "missingParams": "Missing authentication parameters", + "unexpectedError": "An unexpected error occurred during authentication", + "backToLogin": "Back to Login" } }, "settings": { @@ -218,6 +230,17 @@ "themeLight": "Light", "themeDark": "Dark", "themeSystem": "System" + }, + "linkedAccounts": { + "pageTitle": "Linked Accounts", + "pageSubtitle": "Manage your linked social accounts", + "title": "Connected Accounts", + "description": "Connect your account with social providers for easier sign-in", + "linked": "Connected", + "link": "Connect", + "unlink": "Disconnect", + "linkError": "Failed to connect account", + "unlinkError": "Failed to disconnect account" } }, "errors": { diff --git a/frontend/messages/it.json b/frontend/messages/it.json index 88e0e6c..523c115 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -143,6 +143,18 @@ "hasNumber": "Contiene un numero", "hasUppercase": "Contiene una lettera maiuscola" } + }, + "oauth": { + "divider": "Oppure continua con", + "loading": "Caricamento provider...", + "continueWith": "Continua con {provider}", + "signUpWith": "Registrati con {provider}", + "processing": "Completamento autenticazione...", + "authFailed": "Autenticazione Fallita", + "providerError": "Il provider di autenticazione ha restituito un errore", + "missingParams": "Parametri di autenticazione mancanti", + "unexpectedError": "Si è verificato un errore durante l'autenticazione", + "backToLogin": "Torna al Login" } }, "settings": { @@ -218,6 +230,17 @@ "themeLight": "Chiaro", "themeDark": "Scuro", "themeSystem": "Sistema" + }, + "linkedAccounts": { + "pageTitle": "Account Collegati", + "pageSubtitle": "Gestisci i tuoi account social collegati", + "title": "Account Connessi", + "description": "Collega il tuo account con i provider social per un accesso più semplice", + "linked": "Connesso", + "link": "Connetti", + "unlink": "Scollega", + "linkError": "Impossibile connettere l'account", + "unlinkError": "Impossibile scollegare l'account" } }, "errors": { diff --git a/frontend/src/app/[locale]/(auth)/auth/callback/[provider]/page.tsx b/frontend/src/app/[locale]/(auth)/auth/callback/[provider]/page.tsx new file mode 100644 index 0000000..01072df --- /dev/null +++ b/frontend/src/app/[locale]/(auth)/auth/callback/[provider]/page.tsx @@ -0,0 +1,107 @@ +/** + * OAuth Callback Page + * Handles the redirect from OAuth providers after authentication + */ + +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { useRouter } from '@/lib/i18n/routing'; +import { useTranslations } from 'next-intl'; +import { Alert } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; +import { useOAuthCallback } from '@/lib/api/hooks/useOAuth'; +import config from '@/config/app.config'; + +export default function OAuthCallbackPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const t = useTranslations('auth.oauth'); + + const [error, setError] = useState(null); + const oauthCallback = useOAuthCallback(); + const hasProcessed = useRef(false); + + const provider = params.provider as string; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const errorParam = searchParams.get('error'); + const errorDescription = searchParams.get('error_description'); + + useEffect(() => { + // Prevent double processing in StrictMode + if (hasProcessed.current) return; + + // Handle OAuth provider error + if (errorParam) { + setError(errorDescription || t('providerError')); + return; + } + + // Validate required parameters + if (!code || !state) { + setError(t('missingParams')); + return; + } + + hasProcessed.current = true; + + // Process the OAuth callback + oauthCallback.mutate( + { provider, code, state }, + { + onSuccess: (data) => { + // Get the stored mode to determine redirect + const mode = sessionStorage.getItem('oauth_mode'); + + if (data?.tokens?.is_new_user) { + // New user - redirect to profile to complete setup + router.push(config.routes.profile); + } else if (mode === 'link') { + // Account linking - redirect to settings + router.push('/settings/profile'); + } else { + // Regular login - redirect to dashboard + router.push(config.routes.dashboard); + } + }, + onError: (err) => { + const errorMessage = err instanceof Error ? err.message : t('unexpectedError'); + setError(errorMessage); + }, + } + ); + }, [provider, code, state, errorParam, errorDescription, oauthCallback, router, t]); + + // Show error state + if (error) { + return ( +
+
+ +

{t('authFailed')}

+

{error}

+
+
+ +
+
+
+ ); + } + + // Show loading state + return ( +
+
+ +

{t('processing')}

+
+
+ ); +} diff --git a/frontend/src/app/[locale]/(authenticated)/settings/accounts/page.tsx b/frontend/src/app/[locale]/(authenticated)/settings/accounts/page.tsx new file mode 100644 index 0000000..4ee36b6 --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/settings/accounts/page.tsx @@ -0,0 +1,24 @@ +/** + * Linked Accounts Settings Page + * Manage linked OAuth provider accounts + */ + +'use client'; + +import { useTranslations } from 'next-intl'; +import { LinkedAccountsSettings } from '@/components/settings'; + +export default function LinkedAccountsPage() { + const t = useTranslations('settings.linkedAccounts'); + + return ( +
+
+

{t('pageTitle')}

+

{t('pageSubtitle')}

+
+ + +
+ ); +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 0f164c2..79ae1cf 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -20,6 +20,7 @@ import { Alert } from '@/components/ui/alert'; import { useLogin } from '@/lib/api/hooks/useAuth'; import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/errors'; import config from '@/config/app.config'; +import { OAuthButtons } from './OAuthButtons'; // ============================================================================ // Validation Schema @@ -49,6 +50,8 @@ interface LoginFormProps { showRegisterLink?: boolean; /** Show password reset link */ showPasswordResetLink?: boolean; + /** Show OAuth provider buttons */ + showOAuthButtons?: boolean; /** Custom className for form container */ className?: string; } @@ -75,6 +78,7 @@ export function LoginForm({ onSuccess, showRegisterLink = true, showPasswordResetLink = true, + showOAuthButtons = true, className, }: LoginFormProps) { const t = useTranslations('auth.login'); @@ -216,6 +220,9 @@ export function LoginForm({ {isSubmitting ? t('loginButtonLoading') : t('loginButton')} + {/* OAuth Buttons */} + {showOAuthButtons && } + {/* Registration Link */} {showRegisterLink && config.features.enableRegistration && (

diff --git a/frontend/src/components/auth/OAuthButtons.tsx b/frontend/src/components/auth/OAuthButtons.tsx new file mode 100644 index 0000000..9feadca --- /dev/null +++ b/frontend/src/components/auth/OAuthButtons.tsx @@ -0,0 +1,158 @@ +/** + * OAuthButtons Component + * Displays OAuth provider buttons for login/registration + */ + +'use client'; + +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { useOAuthProviders, useOAuthStart } from '@/lib/api/hooks/useOAuth'; +import config from '@/config/app.config'; +import { cn } from '@/lib/utils'; + +// ============================================================================ +// Provider Icons +// ============================================================================ + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const providerIcons: Record> = { + google: GoogleIcon, + github: GitHubIcon, +}; + +// ============================================================================ +// Component +// ============================================================================ + +interface OAuthButtonsProps { + /** Mode for OAuth flow */ + mode: 'login' | 'register'; + /** Show divider with "or" text */ + showDivider?: boolean; + /** Custom className */ + className?: string; + /** Called when OAuth flow starts */ + onStart?: (provider: string) => void; + /** Called on error */ + onError?: (error: Error) => void; +} + +/** + * OAuthButtons - Display OAuth provider buttons + * + * Fetches available providers from the backend and displays + * login/registration buttons for each enabled provider. + * + * @example + * ```tsx + * + * ``` + */ +export function OAuthButtons({ + mode, + showDivider = true, + className, + onStart, + onError, +}: OAuthButtonsProps) { + const t = useTranslations('auth.oauth'); + const { data: providersData, isLoading, error } = useOAuthProviders(); + const oauthStart = useOAuthStart(); + + // Don't render if OAuth is not enabled or no providers available + if (isLoading) { + return ( +

+ {showDivider && } +
+ +
+
+ ); + } + + if (error || !providersData?.enabled || !providersData?.providers?.length) { + return null; + } + + const handleOAuthClick = async (provider: string) => { + try { + onStart?.(provider); + await oauthStart.mutateAsync({ provider, mode }); + } catch (err) { + onError?.(err as Error); + } + }; + + return ( +
+ {showDivider && } + +
+ {providersData.providers.map((provider) => { + const providerConfig = config.oauth.providers[provider.provider]; + const Icon = providerIcons[provider.provider]; + + return ( + + ); + })} +
+
+ ); +} + +// ============================================================================ +// Divider Component +// ============================================================================ + +function Divider() { + const t = useTranslations('auth.oauth'); + + return ( +
+
+ +
+
+ {t('divider')} +
+
+ ); +} + +export default OAuthButtons; diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index d8a433e..d57d384 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -19,6 +19,7 @@ import { Alert } from '@/components/ui/alert'; import { useRegister } from '@/lib/api/hooks/useAuth'; import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/errors'; import config from '@/config/app.config'; +import { OAuthButtons } from './OAuthButtons'; // ============================================================================ // Validation Schema @@ -58,6 +59,8 @@ interface RegisterFormProps { onSuccess?: () => void; /** Show login link */ showLoginLink?: boolean; + /** Show OAuth provider buttons */ + showOAuthButtons?: boolean; /** Custom className for form container */ className?: string; } @@ -81,7 +84,12 @@ interface RegisterFormProps { * /> * ``` */ -export function RegisterForm({ onSuccess, showLoginLink = true, className }: RegisterFormProps) { +export function RegisterForm({ + onSuccess, + showLoginLink = true, + showOAuthButtons = true, + className, +}: RegisterFormProps) { const t = useTranslations('auth.register'); const tValidation = useTranslations('validation'); const [serverError, setServerError] = useState(null); @@ -309,6 +317,9 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg {isSubmitting ? t('registerButtonLoading') : t('registerButton')} + {/* OAuth Buttons */} + {showOAuthButtons && } + {/* Login Link */} {showLoginLink && (

diff --git a/frontend/src/components/settings/LinkedAccountsSettings.tsx b/frontend/src/components/settings/LinkedAccountsSettings.tsx new file mode 100644 index 0000000..02b4f93 --- /dev/null +++ b/frontend/src/components/settings/LinkedAccountsSettings.tsx @@ -0,0 +1,195 @@ +/** + * LinkedAccountsSettings Component + * Manage linked OAuth provider accounts + */ + +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { Alert } from '@/components/ui/alert'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Link as LinkIcon, Unlink, AlertTriangle } from 'lucide-react'; +import { + useOAuthProviders, + useOAuthAccounts, + useOAuthLink, + useOAuthUnlink, +} from '@/lib/api/hooks/useOAuth'; +import config from '@/config/app.config'; +import { cn } from '@/lib/utils'; + +// ============================================================================ +// Provider Icons +// ============================================================================ + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const providerIcons: Record> = { + google: GoogleIcon, + github: GitHubIcon, +}; + +// ============================================================================ +// Component +// ============================================================================ + +interface LinkedAccountsSettingsProps { + className?: string; +} + +export function LinkedAccountsSettings({ className }: LinkedAccountsSettingsProps) { + const t = useTranslations('settings.linkedAccounts'); + const [error, setError] = useState(null); + const [unlinkingProvider, setUnlinkingProvider] = useState(null); + + const { data: providersData, isLoading: providersLoading } = useOAuthProviders(); + const { data: accountsData, isLoading: accountsLoading } = useOAuthAccounts(); + const linkMutation = useOAuthLink(); + const unlinkMutation = useOAuthUnlink(); + + const isLoading = providersLoading || accountsLoading; + + // Don't render if OAuth is not enabled + if (!isLoading && (!providersData?.enabled || !providersData?.providers?.length)) { + return null; + } + + const linkedProviders = new Set(accountsData?.accounts?.map((a) => a.provider) || []); + const availableProviders = providersData?.providers || []; + + const handleLink = async (provider: string) => { + try { + setError(null); + await linkMutation.mutateAsync({ provider }); + } catch (err) { + setError(err instanceof Error ? err.message : t('linkError')); + } + }; + + const handleUnlink = async (provider: string) => { + try { + setError(null); + setUnlinkingProvider(provider); + await unlinkMutation.mutateAsync({ provider }); + } catch (err) { + setError(err instanceof Error ? err.message : t('unlinkError')); + } finally { + setUnlinkingProvider(null); + } + }; + + return ( + + + {t('title')} + {t('description')} + + + {error && ( + + +

{error}

+ + )} + + {isLoading ? ( +
+ +
+ ) : ( +
+ {availableProviders.map((provider) => { + const providerConfig = config.oauth.providers[provider.provider]; + const Icon = providerIcons[provider.provider]; + const isLinked = linkedProviders.has(provider.provider); + const linkedAccount = accountsData?.accounts?.find( + (a) => a.provider === provider.provider + ); + const isUnlinking = unlinkingProvider === provider.provider; + + return ( +
+
+ {Icon && } +
+

{providerConfig?.name || provider.name}

+ {isLinked && linkedAccount?.provider_email && ( +

+ {linkedAccount.provider_email} +

+ )} +
+ {isLinked && ( + + {t('linked')} + + )} +
+ + {isLinked ? ( + + ) : ( + + )} +
+ ); + })} +
+ )} + + + ); +} + +export default LinkedAccountsSettings; diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 0e7bb9c..8d8e913 100755 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -5,3 +5,4 @@ export { ProfileSettingsForm } from './ProfileSettingsForm'; export { PasswordChangeForm } from './PasswordChangeForm'; export { SessionCard } from './SessionCard'; export { SessionsManager } from './SessionsManager'; +export { LinkedAccountsSettings } from './LinkedAccountsSettings'; diff --git a/frontend/src/config/app.config.ts b/frontend/src/config/app.config.ts index 196bfba..089966f 100644 --- a/frontend/src/config/app.config.ts +++ b/frontend/src/config/app.config.ts @@ -115,6 +115,24 @@ export const config = { enableSessionManagement: parseBool(ENV.ENABLE_SESSION_MANAGEMENT, true), }, + oauth: { + // OAuth callback URL (for redirects after OAuth provider auth) + callbackPath: '/auth/callback', + // Providers configuration (icons and display names) + providers: { + google: { + name: 'Google', + icon: 'google', + color: '#4285F4', + }, + github: { + name: 'GitHub', + icon: 'github', + color: '#24292F', + }, + } as Record, + }, + debug: { api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development', }, diff --git a/frontend/src/lib/api/generated/sdk.gen.ts b/frontend/src/lib/api/generated/sdk.gen.ts index 24ae482..5b51b75 100644 --- a/frontend/src/lib/api/generated/sdk.gen.ts +++ b/frontend/src/lib/api/generated/sdk.gen.ts @@ -3,7 +3,7 @@ import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; +import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOauthAuthorizationUrlData, GetOauthAuthorizationUrlErrors, GetOauthAuthorizationUrlResponses, GetOauthServerMetadataData, GetOauthServerMetadataResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HandleOauthCallbackData, HandleOauthCallbackErrors, HandleOauthCallbackResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListOauthAccountsData, ListOauthAccountsResponses, ListOauthProvidersData, ListOauthProvidersResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, OauthProviderAuthorizeData, OauthProviderAuthorizeErrors, OauthProviderAuthorizeResponses, OauthProviderRevokeData, OauthProviderRevokeErrors, OauthProviderRevokeResponses, OauthProviderTokenData, OauthProviderTokenErrors, OauthProviderTokenResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterOauthClientData, RegisterOauthClientErrors, RegisterOauthClientResponses, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, StartOauthLinkData, StartOauthLinkErrors, StartOauthLinkResponses, UnlinkOauthAccountData, UnlinkOauthAccountErrors, UnlinkOauthAccountResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; export type Options = Options2 & { /** @@ -224,6 +224,240 @@ export const logoutAll = (options?: Option }); }; +/** + * List OAuth Providers + * + * Get list of enabled OAuth providers for the login/register UI. + * + * Returns: + * List of enabled providers with display info. + */ +export const listOauthProviders = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + url: '/api/v1/oauth/providers', + ...options + }); +}; + +/** + * Get OAuth Authorization URL + * + * Get the authorization URL to redirect the user to the OAuth provider. + * + * The frontend should redirect the user to the returned URL. + * After authentication, the provider will redirect back to the callback URL. + * + * **Rate Limit**: 10 requests/minute + */ +export const getOauthAuthorizationUrl = (options: Options) => { + return (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/oauth/authorize/{provider}', + ...options + }); +}; + +/** + * OAuth Callback + * + * Handle OAuth callback from provider. + * + * The frontend should call this endpoint with the code and state + * parameters received from the OAuth provider redirect. + * + * Returns: + * JWT tokens for the authenticated user. + * + * **Rate Limit**: 10 requests/minute + */ +export const handleOauthCallback = (options: Options) => { + return (options.client ?? client).post({ + responseType: 'json', + url: '/api/v1/oauth/callback/{provider}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * List Linked OAuth Accounts + * + * Get list of OAuth accounts linked to the current user. + * + * Requires authentication. + */ +export const listOauthAccounts = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/accounts', + ...options + }); +}; + +/** + * Unlink OAuth Account + * + * Unlink an OAuth provider from the current user. + * + * The user must have either a password set or another OAuth provider + * linked to ensure they can still log in. + * + * **Rate Limit**: 5 requests/minute + */ +export const unlinkOauthAccount = (options: Options) => { + return (options.client ?? client).delete({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/accounts/{provider}', + ...options + }); +}; + +/** + * Start Account Linking + * + * Start the OAuth flow to link a new provider to the current user. + * + * This is a convenience endpoint that redirects to /authorize/{provider} + * with the current user context. + * + * **Rate Limit**: 10 requests/minute + */ +export const startOauthLink = (options: Options) => { + return (options.client ?? client).post({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/link/{provider}', + ...options + }); +}; + +/** + * OAuth Server Metadata + * + * OAuth 2.0 Authorization Server Metadata (RFC 8414). + * + * Returns server metadata including supported endpoints, scopes, + * and capabilities for MCP clients. + */ +export const getOauthServerMetadata = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + url: '/api/v1/oauth/.well-known/oauth-authorization-server', + ...options + }); +}; + +/** + * Authorization Endpoint (Skeleton) + * + * OAuth 2.0 Authorization Endpoint. + * + * **NOTE**: This is a skeleton implementation. In a full implementation, + * this would: + * 1. Validate client_id and redirect_uri + * 2. Display consent screen to user + * 3. Generate authorization code + * 4. Redirect back to client with code + * + * Currently returns a 501 Not Implemented response. + */ +export const oauthProviderAuthorize = (options: Options) => { + return (options.client ?? client).get({ + responseType: 'json', + url: '/api/v1/oauth/provider/authorize', + ...options + }); +}; + +/** + * Token Endpoint (Skeleton) + * + * OAuth 2.0 Token Endpoint. + * + * **NOTE**: This is a skeleton implementation. In a full implementation, + * this would exchange authorization codes for access tokens. + * + * Currently returns a 501 Not Implemented response. + */ +export const oauthProviderToken = (options: Options) => { + return (options.client ?? client).post({ + ...urlSearchParamsBodySerializer, + responseType: 'json', + url: '/api/v1/oauth/provider/token', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options.headers + } + }); +}; + +/** + * Token Revocation Endpoint (Skeleton) + * + * OAuth 2.0 Token Revocation Endpoint (RFC 7009). + * + * **NOTE**: This is a skeleton implementation. + * + * Currently returns a 501 Not Implemented response. + */ +export const oauthProviderRevoke = (options: Options) => { + return (options.client ?? client).post({ + ...urlSearchParamsBodySerializer, + responseType: 'json', + url: '/api/v1/oauth/provider/revoke', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options.headers + } + }); +}; + +/** + * Register OAuth Client (Admin) + * + * Register a new OAuth client (admin only). + * + * This endpoint allows creating MCP clients that can authenticate + * against this API. + * + * **NOTE**: This is a minimal implementation. + */ +export const registerOauthClient = (options: Options) => { + return (options.client ?? client).post({ + ...urlSearchParamsBodySerializer, + responseType: 'json', + url: '/api/v1/oauth/provider/clients', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options.headers + } + }); +}; + /** * List Users * diff --git a/frontend/src/lib/api/generated/types.gen.ts b/frontend/src/lib/api/generated/types.gen.ts index 98630e3..1729611 100644 --- a/frontend/src/lib/api/generated/types.gen.ts +++ b/frontend/src/lib/api/generated/types.gen.ts @@ -145,6 +145,108 @@ export type BodyLoginOauth = { client_secret?: string | null; }; +/** + * Body_oauth_provider_revoke + */ +export type BodyOauthProviderRevoke = { + /** + * Token + * + * Token to revoke + */ + token: string; + /** + * Token Type Hint + * + * Token type hint (access_token, refresh_token) + */ + token_type_hint?: string | null; + /** + * Client Id + * + * Client ID + */ + client_id?: string | null; + /** + * Client Secret + * + * Client secret + */ + client_secret?: string | null; +}; + +/** + * Body_oauth_provider_token + */ +export type BodyOauthProviderToken = { + /** + * Grant Type + * + * Grant type (authorization_code) + */ + grant_type: string; + /** + * Code + * + * Authorization code + */ + code?: string | null; + /** + * Redirect Uri + * + * Redirect URI + */ + redirect_uri?: string | null; + /** + * Client Id + * + * Client ID + */ + client_id?: string | null; + /** + * Client Secret + * + * Client secret + */ + client_secret?: string | null; + /** + * Code Verifier + * + * PKCE code verifier + */ + code_verifier?: string | null; + /** + * Refresh Token + * + * Refresh token + */ + refresh_token?: string | null; +}; + +/** + * Body_register_oauth_client + */ +export type BodyRegisterOauthClient = { + /** + * Client Name + * + * Client application name + */ + client_name: string; + /** + * Redirect Uris + * + * Comma-separated list of redirect URIs + */ + redirect_uris: string; + /** + * Client Type + * + * public or confidential + */ + client_type?: string; +}; + /** * BulkAction * @@ -256,6 +358,230 @@ export type MessageResponse = { message: string; }; +/** + * OAuthAccountResponse + * + * Schema for OAuth account response to clients. + */ +export type OAuthAccountResponse = { + /** + * Provider + * + * OAuth provider name + */ + provider: string; + /** + * Provider Email + * + * Email from OAuth provider + */ + provider_email?: string | null; + /** + * Id + */ + id: string; + /** + * Created At + */ + created_at: string; +}; + +/** + * OAuthAccountsListResponse + * + * Response containing list of linked OAuth accounts. + */ +export type OAuthAccountsListResponse = { + /** + * Accounts + */ + accounts: Array; +}; + +/** + * OAuthCallbackRequest + * + * Request parameters for OAuth callback. + */ +export type OAuthCallbackRequest = { + /** + * Code + * + * Authorization code from provider + */ + code: string; + /** + * State + * + * State parameter for CSRF protection + */ + state: string; +}; + +/** + * OAuthCallbackResponse + * + * Response after successful OAuth authentication. + */ +export type OAuthCallbackResponse = { + /** + * Access Token + * + * JWT access token + */ + access_token: string; + /** + * Refresh Token + * + * JWT refresh token + */ + refresh_token: string; + /** + * Token Type + */ + token_type?: string; + /** + * Expires In + * + * Token expiration in seconds + */ + expires_in: number; + /** + * Is New User + * + * Whether a new user was created + */ + is_new_user?: boolean; +}; + +/** + * OAuthProviderInfo + * + * Information about an available OAuth provider. + */ +export type OAuthProviderInfo = { + /** + * Provider + * + * Provider identifier (google, github) + */ + provider: string; + /** + * Name + * + * Human-readable provider name + */ + name: string; + /** + * Icon + * + * Icon identifier for frontend + */ + icon?: string | null; +}; + +/** + * OAuthProvidersResponse + * + * Response containing list of enabled OAuth providers. + */ +export type OAuthProvidersResponse = { + /** + * Enabled + * + * Whether OAuth is globally enabled + */ + enabled: boolean; + /** + * Providers + * + * List of enabled providers + */ + providers?: Array; +}; + +/** + * OAuthServerMetadata + * + * OAuth 2.0 Authorization Server Metadata (RFC 8414). + */ +export type OAuthServerMetadata = { + /** + * Issuer + * + * Authorization server issuer URL + */ + issuer: string; + /** + * Authorization Endpoint + * + * Authorization endpoint URL + */ + authorization_endpoint: string; + /** + * Token Endpoint + * + * Token endpoint URL + */ + token_endpoint: string; + /** + * Registration Endpoint + * + * Dynamic client registration endpoint + */ + registration_endpoint?: string | null; + /** + * Revocation Endpoint + * + * Token revocation endpoint + */ + revocation_endpoint?: string | null; + /** + * Scopes Supported + * + * Supported scopes + */ + scopes_supported?: Array; + /** + * Response Types Supported + * + * Supported response types + */ + response_types_supported?: Array; + /** + * Grant Types Supported + * + * Supported grant types + */ + grant_types_supported?: Array; + /** + * Code Challenge Methods Supported + * + * Supported PKCE methods + */ + code_challenge_methods_supported?: Array; +}; + +/** + * OAuthUnlinkResponse + * + * Response after unlinking an OAuth account. + */ +export type OAuthUnlinkResponse = { + /** + * Success + * + * Whether the unlink was successful + */ + success: boolean; + /** + * Message + * + * Status message + */ + message: string; +}; + /** * OrgDistributionData */ @@ -1097,6 +1423,352 @@ export type LogoutAllResponses = { export type LogoutAllResponse = LogoutAllResponses[keyof LogoutAllResponses]; +export type ListOauthProvidersData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/oauth/providers'; +}; + +export type ListOauthProvidersResponses = { + /** + * Successful Response + */ + 200: OAuthProvidersResponse; +}; + +export type ListOauthProvidersResponse = ListOauthProvidersResponses[keyof ListOauthProvidersResponses]; + +export type GetOauthAuthorizationUrlData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string; + }; + path: { + /** + * Provider + */ + provider: string; + }; + query: { + /** + * Redirect Uri + * + * Frontend callback URL after OAuth completes + */ + redirect_uri: string; + }; + url: '/api/v1/oauth/authorize/{provider}'; +}; + +export type GetOauthAuthorizationUrlErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetOauthAuthorizationUrlError = GetOauthAuthorizationUrlErrors[keyof GetOauthAuthorizationUrlErrors]; + +export type GetOauthAuthorizationUrlResponses = { + /** + * Response Get Oauth Authorization Url + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetOauthAuthorizationUrlResponse = GetOauthAuthorizationUrlResponses[keyof GetOauthAuthorizationUrlResponses]; + +export type HandleOauthCallbackData = { + body: OAuthCallbackRequest; + path: { + /** + * Provider + */ + provider: string; + }; + query: { + /** + * Redirect Uri + * + * Must match the redirect_uri used in authorization + */ + redirect_uri: string; + }; + url: '/api/v1/oauth/callback/{provider}'; +}; + +export type HandleOauthCallbackErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type HandleOauthCallbackError = HandleOauthCallbackErrors[keyof HandleOauthCallbackErrors]; + +export type HandleOauthCallbackResponses = { + /** + * Successful Response + */ + 200: OAuthCallbackResponse; +}; + +export type HandleOauthCallbackResponse = HandleOauthCallbackResponses[keyof HandleOauthCallbackResponses]; + +export type ListOauthAccountsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/oauth/accounts'; +}; + +export type ListOauthAccountsResponses = { + /** + * Successful Response + */ + 200: OAuthAccountsListResponse; +}; + +export type ListOauthAccountsResponse = ListOauthAccountsResponses[keyof ListOauthAccountsResponses]; + +export type UnlinkOauthAccountData = { + body?: never; + path: { + /** + * Provider + */ + provider: string; + }; + query?: never; + url: '/api/v1/oauth/accounts/{provider}'; +}; + +export type UnlinkOauthAccountErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UnlinkOauthAccountError = UnlinkOauthAccountErrors[keyof UnlinkOauthAccountErrors]; + +export type UnlinkOauthAccountResponses = { + /** + * Successful Response + */ + 200: OAuthUnlinkResponse; +}; + +export type UnlinkOauthAccountResponse = UnlinkOauthAccountResponses[keyof UnlinkOauthAccountResponses]; + +export type StartOauthLinkData = { + body?: never; + path: { + /** + * Provider + */ + provider: string; + }; + query: { + /** + * Redirect Uri + * + * Frontend callback URL after OAuth completes + */ + redirect_uri: string; + }; + url: '/api/v1/oauth/link/{provider}'; +}; + +export type StartOauthLinkErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type StartOauthLinkError = StartOauthLinkErrors[keyof StartOauthLinkErrors]; + +export type StartOauthLinkResponses = { + /** + * Response Start Oauth Link + * + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type StartOauthLinkResponse = StartOauthLinkResponses[keyof StartOauthLinkResponses]; + +export type GetOauthServerMetadataData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/oauth/.well-known/oauth-authorization-server'; +}; + +export type GetOauthServerMetadataResponses = { + /** + * Successful Response + */ + 200: OAuthServerMetadata; +}; + +export type GetOauthServerMetadataResponse = GetOauthServerMetadataResponses[keyof GetOauthServerMetadataResponses]; + +export type OauthProviderAuthorizeData = { + body?: never; + path?: never; + query: { + /** + * Response Type + * + * Must be 'code' + */ + response_type: string; + /** + * Client Id + * + * OAuth client ID + */ + client_id: string; + /** + * Redirect Uri + * + * Redirect URI + */ + redirect_uri: string; + /** + * Scope + * + * Requested scopes + */ + scope?: string; + /** + * State + * + * CSRF state parameter + */ + state?: string; + /** + * Code Challenge + * + * PKCE code challenge + */ + code_challenge?: string | null; + /** + * Code Challenge Method + * + * PKCE method (S256) + */ + code_challenge_method?: string | null; + }; + url: '/api/v1/oauth/provider/authorize'; +}; + +export type OauthProviderAuthorizeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OauthProviderAuthorizeError = OauthProviderAuthorizeErrors[keyof OauthProviderAuthorizeErrors]; + +export type OauthProviderAuthorizeResponses = { + /** + * Response Oauth Provider Authorize + * + * Successful Response + */ + 200: unknown; +}; + +export type OauthProviderTokenData = { + body: BodyOauthProviderToken; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/token'; +}; + +export type OauthProviderTokenErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OauthProviderTokenError = OauthProviderTokenErrors[keyof OauthProviderTokenErrors]; + +export type OauthProviderTokenResponses = { + /** + * Response Oauth Provider Token + * + * Successful Response + */ + 200: unknown; +}; + +export type OauthProviderRevokeData = { + body: BodyOauthProviderRevoke; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/revoke'; +}; + +export type OauthProviderRevokeErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OauthProviderRevokeError = OauthProviderRevokeErrors[keyof OauthProviderRevokeErrors]; + +export type OauthProviderRevokeResponses = { + /** + * Response Oauth Provider Revoke + * + * Successful Response + */ + 200: unknown; +}; + +export type RegisterOauthClientData = { + body: BodyRegisterOauthClient; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/clients'; +}; + +export type RegisterOauthClientErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RegisterOauthClientError = RegisterOauthClientErrors[keyof RegisterOauthClientErrors]; + +export type RegisterOauthClientResponses = { + /** + * Response Register Oauth Client + * + * Successful Response + */ + 200: unknown; +}; + export type ListUsersData = { body?: never; path?: never; diff --git a/frontend/src/lib/api/hooks/useOAuth.ts b/frontend/src/lib/api/hooks/useOAuth.ts new file mode 100644 index 0000000..f5634e0 --- /dev/null +++ b/frontend/src/lib/api/hooks/useOAuth.ts @@ -0,0 +1,235 @@ +/** + * OAuth React Query Hooks + * Provides hooks for OAuth authentication flows + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + listOauthProviders, + getOauthAuthorizationUrl, + handleOauthCallback, + listOauthAccounts, + unlinkOauthAccount, + startOauthLink, + getCurrentUserProfile, +} from '@/lib/api/generated'; +import type { + OAuthProvidersResponse, + OAuthAccountsListResponse, + OAuthCallbackResponse, + UserResponse, +} from '@/lib/api/generated'; +import { useAuth } from '@/lib/auth/AuthContext'; +import config from '@/config/app.config'; + +// ============================================================================ +// Query Keys +// ============================================================================ + +export const oauthKeys = { + all: ['oauth'] as const, + providers: () => [...oauthKeys.all, 'providers'] as const, + accounts: () => [...oauthKeys.all, 'accounts'] as const, +}; + +// ============================================================================ +// Provider Queries +// ============================================================================ + +/** + * Fetch available OAuth providers + * Returns which providers are enabled for login/registration + */ +export function useOAuthProviders() { + return useQuery({ + queryKey: oauthKeys.providers(), + queryFn: async () => { + const response = await listOauthProviders(); + return response.data as OAuthProvidersResponse; + }, + staleTime: 5 * 60 * 1000, // Providers don't change often + gcTime: 30 * 60 * 1000, + }); +} + +// ============================================================================ +// OAuth Flow Mutations +// ============================================================================ + +/** + * Start OAuth login/registration flow + * Redirects user to the OAuth provider + */ +export function useOAuthStart() { + return useMutation({ + mutationFn: async ({ + provider, + mode, + }: { + provider: string; + mode: 'login' | 'register' | 'link'; + }) => { + const redirectUri = `${config.app.url}${config.oauth.callbackPath}/${provider}`; + + const response = await getOauthAuthorizationUrl({ + path: { provider }, + query: { redirect_uri: redirectUri }, + }); + + if (response.data) { + // Store mode in sessionStorage for callback handling + sessionStorage.setItem('oauth_mode', mode); + sessionStorage.setItem('oauth_provider', provider); + + // Response is { [key: string]: unknown }, so cast authorization_url + const authUrl = (response.data as { authorization_url: string }).authorization_url; + // Redirect to OAuth provider + window.location.href = authUrl; + } + + return response.data; + }, + }); +} + +/** + * Handle OAuth callback after provider redirect + * Exchanges the code for tokens and logs the user in + */ +export function useOAuthCallback() { + const { setAuth } = useAuth(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + provider, + code, + state, + }: { + provider: string; + code: string; + state: string; + }) => { + const redirectUri = `${config.app.url}${config.oauth.callbackPath}/${provider}`; + + // Exchange code for tokens + const response = await handleOauthCallback({ + path: { provider }, + query: { redirect_uri: redirectUri }, + body: { + code, + state, + }, + }); + + const tokenData = response.data as OAuthCallbackResponse; + + // Fetch user profile using the new access token + // We need to make this request with the new token + const userResponse = await getCurrentUserProfile({ + headers: { + authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + return { + tokens: tokenData, + user: userResponse.data as UserResponse, + }; + }, + onSuccess: (data) => { + if (data?.tokens && data?.user) { + // Set auth state with tokens and user from OAuth + setAuth( + data.user, + data.tokens.access_token, + data.tokens.refresh_token, + data.tokens.expires_in + ); + + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: ['user'] }); + } + + // Clean up session storage + sessionStorage.removeItem('oauth_mode'); + sessionStorage.removeItem('oauth_provider'); + }, + onError: () => { + // Clean up session storage on error too + sessionStorage.removeItem('oauth_mode'); + sessionStorage.removeItem('oauth_provider'); + }, + }); +} + +// ============================================================================ +// Account Management +// ============================================================================ + +/** + * Fetch linked OAuth accounts for the current user + */ +export function useOAuthAccounts() { + const { isAuthenticated } = useAuth(); + + return useQuery({ + queryKey: oauthKeys.accounts(), + queryFn: async () => { + const response = await listOauthAccounts(); + return response.data as OAuthAccountsListResponse; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, // 1 minute + }); +} + +/** + * Start OAuth account linking flow + * For users who want to add another OAuth provider to their account + */ +export function useOAuthLink() { + return useMutation({ + mutationFn: async ({ provider }: { provider: string }) => { + const redirectUri = `${config.app.url}${config.oauth.callbackPath}/${provider}`; + + const response = await startOauthLink({ + path: { provider }, + query: { redirect_uri: redirectUri }, + }); + + if (response.data) { + // Store mode in sessionStorage for callback handling + sessionStorage.setItem('oauth_mode', 'link'); + sessionStorage.setItem('oauth_provider', provider); + + // Response is { [key: string]: unknown }, so cast authorization_url + const authUrl = (response.data as { authorization_url: string }).authorization_url; + // Redirect to OAuth provider + window.location.href = authUrl; + } + + return response.data; + }, + }); +} + +/** + * Unlink an OAuth account from the current user + */ +export function useOAuthUnlink() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ provider }: { provider: string }) => { + const response = await unlinkOauthAccount({ + path: { provider }, + }); + return response.data; + }, + onSuccess: () => { + // Invalidate accounts query to refresh the list + queryClient.invalidateQueries({ queryKey: oauthKeys.accounts() }); + }, + }); +} diff --git a/frontend/src/mocks/handlers/generated.ts b/frontend/src/mocks/handlers/generated.ts index b53b66e..9d0f81b 100644 --- a/frontend/src/mocks/handlers/generated.ts +++ b/frontend/src/mocks/handlers/generated.ts @@ -8,7 +8,7 @@ * * For custom handler behavior, use src/mocks/handlers/overrides.ts * - * Generated: 2025-11-24T17:58:16.943Z + * Generated: 2025-11-25T00:22:46.981Z */ import { http, HttpResponse, delay } from 'msw'; @@ -93,7 +93,6 @@ export const generatedHandlers = [ refresh_token: refreshToken, token_type: 'bearer', expires_in: 900, - user: user, }); }),