mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
## Summary The dirtyClose modal had three buttons (`Cancel | No | Save`) and the sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a single early return — so today clicking "No" *cancels* sign-out instead of signing out without saving, and clicking "Save" never actually saves before logging out. This PR drops `Cancel` for `dirtyClose`, gives each caller a context-specific deny label, and fixes the sign-out 3-state handling. - Fixes [FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels) ## Changes - **What**: - `ConfirmationDialogContent.vue`: hide `Cancel` for `type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save` (preserves work on Enter). - `dialogService.confirm()`: accept and forward `denyLabel`. - `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out anyway, no save) / `true` (save each modified workflow, then logout) distinctly. Pass `denyLabel: 'Sign out anyway'`. - `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`. - i18n: add `auth.signOut.signOutAnyway` and `sideToolbar.workflowTab.closeAnyway`. - **Breaking**: none. The `denyLabel` prop is optional and falls back to `g.no`. ## Review Focus - The "Save" branch in `useAuthActions.logout` now iterates `workflowStore.modifiedWorkflows` and awaits `useWorkflowService().saveWorkflow(workflow)` for each before calling `authStore.logout()`. The close-tab path (`workflowService.closeWorkflow`) was already correct — only the sign-out path needed the same shape. - `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for `dirtyClose`) to `Save`. The dialog is still dismissable via ESC / outside-click, which routes through `dialogComponentProps.onClose → resolve(null)` — sign-out and close-tab both treat `null` as cancel. - Out of scope: the native browser `beforeunload` warning (`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches the in-app modal. ## Tests - Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false` / `true` / no-modified-workflows; saves *every* modified workflow before `authStore.logout`; passes `denyLabel='Sign out anyway'`. - Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for `dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when omitted. - E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway` (not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC keeps the tab. ## screenshot ### AS IS <img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM" src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85" /> <img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM" src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0" /> ### TO BE <img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM" src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841" /> <img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM" src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990) by [Unito](https://www.unito.io)
260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
import { FirebaseError } from 'firebase/app'
|
|
import { AuthErrorCodes } from 'firebase/auth'
|
|
import { ref } from 'vue'
|
|
|
|
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
|
import { t } from '@/i18n'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import type { BillingPortalTargetTier } from '@/stores/authStore'
|
|
import { usdToMicros } from '@/utils/formatUtil'
|
|
|
|
/**
|
|
* Service for Firebase Auth actions.
|
|
* All actions are wrapped with error handling.
|
|
* @returns {Object} - Object containing all Firebase Auth actions
|
|
*/
|
|
export const useAuthActions = () => {
|
|
const authStore = useAuthStore()
|
|
const toastStore = useToastStore()
|
|
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
|
|
|
|
const accessError = ref(false)
|
|
|
|
const reportError = (error: unknown) => {
|
|
// Ref: https://firebase.google.com/docs/auth/admin/errors
|
|
if (
|
|
error instanceof FirebaseError &&
|
|
[
|
|
'auth/unauthorized-domain',
|
|
'auth/invalid-dynamic-link-domain',
|
|
'auth/unauthorized-continue-uri'
|
|
].includes(error.code)
|
|
) {
|
|
accessError.value = true
|
|
toastStore.add({
|
|
severity: 'error',
|
|
summary: t('g.error'),
|
|
detail: t('toastMessages.unauthorizedDomain', {
|
|
domain: window.location.hostname,
|
|
email: 'support@comfy.org'
|
|
})
|
|
})
|
|
} else {
|
|
toastErrorHandler(error)
|
|
}
|
|
}
|
|
|
|
const logout = wrapWithErrorHandlingAsync(async () => {
|
|
const workflowStore = useWorkflowStore()
|
|
const modifiedWorkflows = workflowStore.modifiedWorkflows
|
|
if (modifiedWorkflows.length > 0) {
|
|
const dialogService = useDialogService()
|
|
const confirmed = await dialogService.confirm({
|
|
title: t('auth.signOut.unsavedChangesTitle'),
|
|
message: t('auth.signOut.unsavedChangesMessage'),
|
|
type: 'dirtyClose',
|
|
denyLabel: t('auth.signOut.signOutAnyway')
|
|
})
|
|
if (confirmed === null) return
|
|
|
|
if (confirmed === true) {
|
|
const workflowService = useWorkflowService()
|
|
for (const workflow of modifiedWorkflows) {
|
|
try {
|
|
const saved = await workflowService.saveWorkflow(workflow)
|
|
if (!saved) return
|
|
} catch {
|
|
throw new Error(
|
|
t('auth.signOut.saveFailed', { workflow: workflow.path })
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await authStore.logout()
|
|
toastStore.add({
|
|
severity: 'success',
|
|
summary: t('auth.signOut.success'),
|
|
detail: t('auth.signOut.successDetail'),
|
|
life: 5000
|
|
})
|
|
|
|
if (isCloud) {
|
|
try {
|
|
window.location.href = '/cloud/login'
|
|
} catch (error) {
|
|
// needed for local development until we bring in cloud login pages.
|
|
window.location.reload()
|
|
}
|
|
}
|
|
}, reportError)
|
|
|
|
const sendPasswordReset = wrapWithErrorHandlingAsync(
|
|
async (email: string) => {
|
|
await authStore.sendPasswordReset(email)
|
|
toastStore.add({
|
|
severity: 'success',
|
|
summary: t('auth.login.passwordResetSent'),
|
|
detail: t('auth.login.passwordResetSentDetail'),
|
|
life: 5000
|
|
})
|
|
},
|
|
reportError
|
|
)
|
|
|
|
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
|
const { isActiveSubscription } = useBillingContext()
|
|
if (!isActiveSubscription.value) return
|
|
|
|
const response = await authStore.initiateCreditPurchase({
|
|
amount_micros: usdToMicros(amount),
|
|
currency: 'usd'
|
|
})
|
|
|
|
if (!response.checkout_url) {
|
|
throw new Error(
|
|
t('toastMessages.failedToPurchaseCredits', {
|
|
error: 'No checkout URL returned'
|
|
})
|
|
)
|
|
}
|
|
|
|
useTelemetry()?.startTopupTracking()
|
|
window.open(response.checkout_url, '_blank')
|
|
}, reportError)
|
|
|
|
const accessBillingPortal = wrapWithErrorHandlingAsync<
|
|
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
|
|
boolean
|
|
>(async (targetTier, openInNewTab = true) => {
|
|
const response = await authStore.accessBillingPortal(targetTier)
|
|
if (!response.billing_portal_url) {
|
|
throw new Error(
|
|
t('toastMessages.failedToAccessBillingPortal', {
|
|
error: 'No billing portal URL returned'
|
|
})
|
|
)
|
|
}
|
|
if (openInNewTab) {
|
|
return window.open(response.billing_portal_url, '_blank') !== null
|
|
}
|
|
|
|
globalThis.location.href = response.billing_portal_url
|
|
return true
|
|
}, reportError)
|
|
|
|
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
|
const result = await authStore.fetchBalance()
|
|
// Top-up completion tracking happens in UsageLogsTable when events are fetched
|
|
return result
|
|
}, reportError)
|
|
|
|
const signInWithGoogle = wrapWithErrorHandlingAsync(
|
|
async (options?: { isNewUser?: boolean }) => {
|
|
return await authStore.loginWithGoogle(options)
|
|
},
|
|
reportError
|
|
)
|
|
|
|
const signInWithGithub = wrapWithErrorHandlingAsync(
|
|
async (options?: { isNewUser?: boolean }) => {
|
|
return await authStore.loginWithGithub(options)
|
|
},
|
|
reportError
|
|
)
|
|
|
|
const signInWithEmail = wrapWithErrorHandlingAsync(
|
|
async (email: string, password: string) => {
|
|
return await authStore.login(email, password)
|
|
},
|
|
reportError
|
|
)
|
|
|
|
const signUpWithEmail = wrapWithErrorHandlingAsync(
|
|
async (email: string, password: string) => {
|
|
return await authStore.register(email, password)
|
|
},
|
|
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)
|
|
toastStore.add({
|
|
severity: 'success',
|
|
summary: t('auth.passwordUpdate.success'),
|
|
detail: t('auth.passwordUpdate.successDetail'),
|
|
life: 5000
|
|
})
|
|
},
|
|
reportError,
|
|
undefined,
|
|
[createReauthenticationRecovery<[string], void>()]
|
|
)
|
|
|
|
return {
|
|
logout,
|
|
sendPasswordReset,
|
|
purchaseCredits,
|
|
accessBillingPortal,
|
|
fetchBalance,
|
|
signInWithGoogle,
|
|
signInWithGithub,
|
|
signInWithEmail,
|
|
signUpWithEmail,
|
|
updatePassword,
|
|
accessError,
|
|
reportError
|
|
}
|
|
}
|