Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 61 additions & 10 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'

import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'

Expand Down Expand Up @@ -122,6 +125,47 @@ export const useFirebaseAuthActions = () => {
reportError
)

/**
* Recovery strategy for Firebase auth/requires-recent-login errors.
* Prompts user to reauthenticate and retries the operation after successful login.
*/
const createReauthenticationRecovery = <
TArgs extends unknown[],
TReturn
>(): ErrorRecoveryStrategy<TArgs, TReturn> => {
const dialogService = useDialogService()

return {
shouldHandle: (error: unknown) =>
error instanceof FirebaseError &&
error.code === AuthErrorCodes.CREDENTIAL_TOO_OLD_LOGIN_AGAIN,

recover: async (
_error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => {
const confirmed = await dialogService.confirm({
title: t('auth.reauthRequired.title'),
message: t('auth.reauthRequired.message'),
type: 'default'
})

if (!confirmed) {
return
}

await authStore.logout()

const signedIn = await dialogService.showSignInDialog()

if (signedIn) {
await retry(...args)
}
}
}
}

const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => {
await authStore.updatePassword(newPassword)
Expand All @@ -132,18 +176,25 @@ export const useFirebaseAuthActions = () => {
life: 5000
})
},
reportError
reportError,
undefined,
[createReauthenticationRecovery<[string], void>()]
)

const deleteAccount = wrapWithErrorHandlingAsync(async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
}, reportError)
const deleteAccount = wrapWithErrorHandlingAsync(
async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[], void>()]
)

return {
logout,
Expand Down
70 changes: 65 additions & 5 deletions src/composables/useErrorHandling.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'

/**
* Strategy for recovering from specific error conditions.
* Allows operations to be retried after resolving the error condition.
*
* @template TArgs - The argument types of the operation to be retried
* @template TReturn - The return type of the operation
*
* @example
* ```typescript
* const networkRecovery: ErrorRecoveryStrategy = {
* shouldHandle: (error) => error instanceof NetworkError,
* recover: async (error, retry) => {
* await waitForNetwork()
* await retry()
* }
* }
* ```
*/
export interface ErrorRecoveryStrategy<
TArgs extends unknown[] = unknown[],
TReturn = unknown
> {
/**
* Determines if this strategy should handle the given error.
* @param error - The error to check
* @returns true if this strategy can handle the error
*/
shouldHandle: (error: unknown) => boolean

/**
* Attempts to recover from the error and retry the operation.
* This function is responsible for:
* 1. Resolving the error condition (e.g., reauthentication, network reconnect)
* 2. Calling retry() to re-execute the original operation
* 3. Handling the retry result (success or failure)
*
* @param error - The error that occurred
* @param retry - Function to retry the original operation
* @param args - Original arguments passed to the operation
* @returns Promise that resolves when recovery completes (whether successful or not)
*/
recover: (
error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => Promise<void>
}

export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: unknown) => {
Expand All @@ -13,9 +61,9 @@ export function useErrorHandling() {
}

const wrapWithErrorHandling =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => TReturn,
errorHandler?: (error: any) => void,
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void
) =>
(...args: TArgs): TReturn | undefined => {
Expand All @@ -29,15 +77,27 @@ export function useErrorHandling() {
}

const wrapWithErrorHandlingAsync =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void,
recoveryStrategies: ErrorRecoveryStrategy<TArgs, TReturn>[] = []
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
for (const strategy of recoveryStrategies) {
if (strategy.shouldHandle(e)) {
try {
await strategy.recover(e, action, args)
return
} catch (recoveryError) {
console.error('Recovery strategy failed:', recoveryError)
}
}
}

;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()
Expand Down
6 changes: 6 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,12 @@
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
},
"reauthRequired": {
"title": "Re-authentication Required",
"message": "For security reasons, this action requires you to sign in again. Would you like to proceed?",
"confirm": "Sign In Again",
"cancel": "Cancel"
},
"loginButton": {
"tooltipHelp": "Login to be able to use \"API Nodes\"",
"tooltipLearnMore": "Learn more..."
Expand Down
2 changes: 1 addition & 1 deletion src/types/treeExplorerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
event: MouseEvent
) => void | Promise<void>
/** Function to handle errors */
handleError?: (this: TreeExplorerNode<T>, error: Error) => void
handleError?: (this: TreeExplorerNode<T>, error: unknown) => void
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]
Expand Down
Loading
Loading