fix(web): render a message in OAuth callback without opener (#43053)

Co-authored-by: Taranum01 <taranumwasu@users.noreply.github.com>
Co-authored-by: Taranum01 <taranum01@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-29 10:02:45 +00:00
1 parent a67cd87eb0
commit f79ed842fa
31 files changed
+415 -42

No files matched your search

-5
View File
@@ -4171,11 +4171,6 @@
"count": 2
}
},
"web/hooks/use-oauth.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/hooks/use-pay.tsx": {
"eslint-react/set-state-in-effect": {
"count": 4
@@ -0,0 +1,100 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createReactI18nextMock } from '@/test/i18n-mock'
vi.mock('react-i18next', () =>
createReactI18nextMock({
'oauth.callback.success': 'Authorization complete',
'oauth.callback.successHint': 'You can now close this tab and return to the previous page.',
'oauth.callback.error': 'Authorization failed',
'oauth.callback.errorHint': 'Please return to the previous page and try again.',
'login.signBtn': 'Sign in',
}),
)
vi.mock('@/hooks/use-oauth', () => ({
useOAuthCallback: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const { useOAuthCallback } = await import('@/hooks/use-oauth')
const OAuthCallback = (await import('../page')).default
const setOpener = (opener: Window | null) => {
Object.defineProperty(window, 'opener', { configurable: true, writable: true, value: opener })
}
describe('OAuthCallback page', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
setOpener(null)
})
it('renders an empty <div /> when opened as a popup (hasOpener=true) so the popup can close cleanly', () => {
setOpener({ postMessage: vi.fn() } as unknown as Window)
vi.mocked(useOAuthCallback).mockReturnValue({
hasOpener: true,
finished: false,
error: null,
errorDescription: null,
})
const { container } = render(<OAuthCallback />)
expect(container.firstChild).not.toBeNull()
expect(container.firstChild!.nodeName).toBe('DIV')
expect(container.firstChild!.childNodes.length).toBe(0)
})
it('renders a success message when opened in a new tab with a subscription_id (#39752)', () => {
setOpener(null)
vi.mocked(useOAuthCallback).mockReturnValue({
hasOpener: false,
finished: true,
error: null,
errorDescription: null,
})
render(<OAuthCallback />)
expect(screen.getByRole('main')).toBeInTheDocument()
expect(screen.getByText('Authorization complete')).toBeInTheDocument()
expect(
screen.getByText('You can now close this tab and return to the previous page.'),
).toBeInTheDocument()
})
it('renders an error message with the provider description when the provider reported an error (#39752)', () => {
setOpener(null)
vi.mocked(useOAuthCallback).mockReturnValue({
hasOpener: false,
finished: true,
error: 'access_denied',
errorDescription: 'Please re-authorize the app.',
})
render(<OAuthCallback />)
expect(screen.getByText('Authorization failed')).toBeInTheDocument()
expect(screen.getByText('Please re-authorize the app.')).toBeInTheDocument()
})
it('falls back to a generic error hint when the provider did not supply an error_description', () => {
setOpener(null)
vi.mocked(useOAuthCallback).mockReturnValue({
hasOpener: false,
finished: true,
error: 'server_error',
errorDescription: null,
})
render(<OAuthCallback />)
expect(screen.getByText('Authorization failed')).toBeInTheDocument()
expect(
screen.getByText('Please return to the previous page and try again.'),
).toBeInTheDocument()
})
})
+24 -3
View File
@@ -4,11 +4,32 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { useOAuthCallback } from '@/hooks/use-oauth'
const OAuthCallback = () => {
const { t } = useTranslation(['login'])
const { t } = useTranslation(['login', 'oauth'])
useDocumentTitle(t(($) => $.signBtn, { ns: 'login' }))
useOAuthCallback()
const { hasOpener, finished, error, errorDescription } = useOAuthCallback()
return <div />
// When the page is opened as a popup by the console, the hook posts the
// OAuth result to the opener and calls window.close(). The page only
// needs to render a fallback message when the page was opened in a new
// tab (no `window.opener`); see issue #39752.
if (hasOpener || !finished) return <div />
return (
<main className="flex min-h-screen items-center justify-center bg-background-default-subtle p-6">
<div className="w-full max-w-md rounded-2xl border border-components-panel-border bg-components-panel-bg p-8 text-center shadow-sm">
<h1 className="text-xl font-semibold text-text-primary">
{error
? t(($) => $['callback.error'], { ns: 'oauth' })
: t(($) => $['callback.success'], { ns: 'oauth' })}
</h1>
<p className="mt-2 text-sm text-text-secondary">
{error
? errorDescription || t(($) => $['callback.errorHint'], { ns: 'oauth' })
: t(($) => $['callback.successHint'], { ns: 'oauth' })}
</p>
</div>
</main>
)
}
export default OAuthCallback
+126
View File
@@ -0,0 +1,126 @@
import { renderHook } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useOAuthCallback } from '../use-oauth'
const originalOpener = window.opener
const originalClose = window.close
const setOpener = (opener: Window | null) => {
Object.defineProperty(window, 'opener', { configurable: true, writable: true, value: opener })
}
describe('useOAuthCallback', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
setOpener(originalOpener)
window.close = originalClose
})
it('posts a success message and closes the popup when window.opener is present and a subscription_id is in the query', () => {
const postMessage = vi.fn()
setOpener({ origin: 'https://console.example.test', postMessage } as unknown as Window)
const close = vi.fn()
window.close = close
window.history.replaceState({}, '', '/oauth-callback?subscription_id=sub-123')
const { result } = renderHook(() => useOAuthCallback())
expect(postMessage).toHaveBeenCalledWith(
{ type: 'oauth_callback', success: true, subscriptionId: 'sub-123' },
'https://console.example.test',
)
expect(close).toHaveBeenCalledTimes(1)
expect(result.current.hasOpener).toBe(true)
expect(result.current.finished).toBe(true)
})
it('posts an error message and closes the popup when window.opener is present and an error is in the query', () => {
const postMessage = vi.fn()
setOpener({ origin: 'https://console.example.test', postMessage } as unknown as Window)
const close = vi.fn()
window.close = close
window.history.replaceState(
{},
'',
'/oauth-callback?error=access_denied&error_description=User%20denied%20access',
)
renderHook(() => useOAuthCallback())
expect(postMessage).toHaveBeenCalledWith(
{
type: 'oauth_callback',
success: false,
error: 'access_denied',
errorDescription: 'User denied access',
},
'https://console.example.test',
)
expect(close).toHaveBeenCalledTimes(1)
})
it('falls back to targetOrigin "*" when window.opener has no origin (e.g. file:// popup)', () => {
const postMessage = vi.fn()
setOpener({ postMessage } as unknown as Window)
const close = vi.fn()
window.close = close
window.history.replaceState({}, '', '/oauth-callback?subscription_id=sub-1')
renderHook(() => useOAuthCallback())
expect(postMessage).toHaveBeenCalledWith(expect.any(Object), '*')
})
it('exposes success state without posting a message when window.opener is missing (new-tab case) (#39752)', () => {
setOpener(null)
const close = vi.fn()
window.close = close
window.history.replaceState({}, '', '/oauth-callback?subscription_id=sub-tab-1')
const { result } = renderHook(() => useOAuthCallback())
// No opener, so we must NOT try to close (would close the only tab).
expect(close).not.toHaveBeenCalled()
// The page needs the state to render a meaningful message instead of an empty <div />.
expect(result.current.hasOpener).toBe(false)
expect(result.current.finished).toBe(true)
expect(result.current.error).toBeNull()
expect(result.current.errorDescription).toBeNull()
})
it('exposes the provider error in state when window.opener is missing (#39752)', () => {
setOpener(null)
const close = vi.fn()
window.close = close
window.history.replaceState(
{},
'',
'/oauth-callback?error=access_denied&error_description=Please%20re-authorize',
)
const { result } = renderHook(() => useOAuthCallback())
expect(close).not.toHaveBeenCalled()
expect(result.current.hasOpener).toBe(false)
expect(result.current.finished).toBe(true)
expect(result.current.error).toBe('access_denied')
expect(result.current.errorDescription).toBe('Please re-authorize')
})
it('renders without browser globals on the server and waits for hydration', () => {
const ServerCallback = () => {
const state = useOAuthCallback()
return <div>{state.finished ? 'finished' : 'waiting'}</div>
}
vi.stubGlobal('window', undefined)
try {
expect(renderToString(<ServerCallback />)).toContain('waiting')
} finally {
vi.unstubAllGlobals()
}
})
})
+61 -34
View File
@@ -1,51 +1,78 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import { validateRedirectUrl } from '@/utils/urlValidation'
export const useOAuthCallback = () => {
export type OAuthCallbackState = {
/** True when the callback tab/popup was opened by another window via window.open. */
hasOpener: boolean
/** True after the callback has posted a message to the opener (or decided not to). */
finished: boolean
/** Provider-supplied error code, if any. */
error: string | null
/** Provider-supplied error description, if any. */
errorDescription: string | null
}
// OAuth redirects load a new page. The URL and opener do not change during
// this page's lifetime, so a subscription is not needed after hydration.
const subscribeToCallback = () => () => {}
const getCallbackSnapshot = () => JSON.stringify([!!window.opener, window.location.search])
const getServerCallbackSnapshot = () => null
export const useOAuthCallback = (): OAuthCallbackState => {
const snapshot = useSyncExternalStore(
subscribeToCallback,
getCallbackSnapshot,
getServerCallbackSnapshot,
)
const [hasOpener, search] =
snapshot === null ? [false, ''] : (JSON.parse(snapshot) as [boolean, string])
const urlParams = new URLSearchParams(search)
const error = urlParams.get('error')
const errorDescription = urlParams.get('error_description')
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
const subscriptionId = urlParams.get('subscription_id')
const error = urlParams.get('error')
const errorDescription = urlParams.get('error_description')
const opener = window.opener
if (window.opener) {
// Use window.opener.origin instead of '*' for security
const targetOrigin = window.opener?.origin || '*'
if (!opener) return
if (subscriptionId) {
window.opener.postMessage(
{
type: 'oauth_callback',
success: true,
subscriptionId,
},
targetOrigin,
)
} else if (error) {
window.opener.postMessage(
{
type: 'oauth_callback',
success: false,
error,
errorDescription,
},
targetOrigin,
)
} else {
window.opener.postMessage(
{
type: 'oauth_callback',
},
targetOrigin,
)
}
window.close()
// Use window.opener.origin instead of '*' for security.
const targetOrigin = opener.origin || '*'
if (subscriptionId) {
opener.postMessage({ type: 'oauth_callback', success: true, subscriptionId }, targetOrigin)
} else if (error) {
opener.postMessage(
{ type: 'oauth_callback', success: false, error, errorDescription },
targetOrigin,
)
} else {
opener.postMessage({ type: 'oauth_callback' }, targetOrigin)
}
window.close()
}, [])
return {
hasOpener,
finished: snapshot !== null,
error,
errorDescription,
}
}
export const openOAuthPopup = (url: string, callback: (data?: any) => void) => {
type OAuthCallbackMessage = {
type: 'oauth_callback'
success?: boolean
subscriptionId?: string
error?: string
errorDescription?: string
}
export const openOAuthPopup = (url: string, callback: (data?: OAuthCallbackMessage) => void) => {
const width = 600
const height = 600
const left = window.screenX + (window.outerWidth - width) / 2
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "فشل التفويض",
"callback.errorHint": "يرجى العودة إلى الصفحة السابقة والمحاولة مرة أخرى.",
"callback.success": "اكتمل التفويض",
"callback.successHint": "يمكنك الآن إغلاق علامة التبويب هذه والعودة إلى الصفحة السابقة.",
"connect": "الاتصال بـ",
"continue": "متابعة",
"error.authAppInfoFetchFailed": "فشل جلب معلومات التطبيق للتفويض",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "İcazə vermək mümkün olmadı",
"callback.errorHint": "Əvvəlki səhifəyə qayıdıb yenidən cəhd edin.",
"callback.success": "İcazə verildi",
"callback.successHint": "İndi bu vərəqi bağlayıb əvvəlki səhifəyə qayıda bilərsiniz.",
"connect": "Qoşulma:",
"continue": "Davam et",
"error.authAppInfoFetchFailed": "İcazə vermək üçün tətbiq məlumatlarını əldə etmək mümkün olmadı",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Autorisierung fehlgeschlagen",
"callback.errorHint": "Bitte kehren Sie zur vorherigen Seite zurück und versuchen Sie es erneut.",
"callback.success": "Autorisierung abgeschlossen",
"callback.successHint": "Sie können diesen Tab jetzt schließen und zur vorherigen Seite zurückkehren.",
"connect": "Verbinden mit",
"continue": "Fortsetzen",
"error.authAppInfoFetchFailed": "Fehler beim Abrufen der App-Informationen für die Autorisierung",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Authorization failed",
"callback.errorHint": "Please return to the previous page and try again.",
"callback.success": "Authorization complete",
"callback.successHint": "You can now close this tab and return to the previous page.",
"connect": "Connect to",
"continue": "Continue",
"error.authAppInfoFetchFailed": "Failed to fetch app info for authorization",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "La autorización falló",
"callback.errorHint": "Vuelve a la página anterior e inténtalo de nuevo.",
"callback.success": "Autorización completada",
"callback.successHint": "Ya puedes cerrar esta pestaña y volver a la página anterior.",
"connect": "Conectar a",
"continue": "Continuar",
"error.authAppInfoFetchFailed": "No se pudo obtener la información de la aplicación para la autorización",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "مجوزدهی ناموفق بود",
"callback.errorHint": "لطفاً به صفحه قبلی برگردید و دوباره تلاش کنید.",
"callback.success": "مجوزدهی تکمیل شد",
"callback.successHint": "اکنون می‌توانید این برگه را ببندید و به صفحه قبلی بازگردید.",
"connect": "متصل به",
"continue": "ادامه دهید",
"error.authAppInfoFetchFailed": "عدم موفقیت در دریافت اطلاعات برنامه برای مجوز",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Échec de l’autorisation",
"callback.errorHint": "Veuillez revenir à la page précédente et réessayer.",
"callback.success": "Autorisation terminée",
"callback.successHint": "Vous pouvez maintenant fermer cet onglet et revenir à la page précédente.",
"connect": "Se connecter à",
"continue": "Continuer",
"error.authAppInfoFetchFailed": "Échec de la récupération des informations de l'application pour l'autorisation",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "प्राधिकरण विफल",
"callback.errorHint": "कृपया पिछले पृष्ठ पर लौटें और फिर से प्रयास करें।",
"callback.success": "प्राधिकरण पूरा हुआ",
"callback.successHint": "अब आप इस टैब को बंद करके पिछले पृष्ठ पर लौट सकते हैं।",
"connect": "संयोजित करें",
"continue": "जारी रखें",
"error.authAppInfoFetchFailed": "प्राधिकरण के लिए ऐप जानकारी प्राप्त करने में असफल हुआ",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Otorisasi gagal",
"callback.errorHint": "Kembali ke halaman sebelumnya dan coba lagi.",
"callback.success": "Otorisasi selesai",
"callback.successHint": "Anda sekarang dapat menutup tab ini dan kembali ke halaman sebelumnya.",
"connect": "Hubungkan",
"continue": "Lanjut",
"error.authAppInfoFetchFailed": "Gagal mengambil info aplikasi untuk otorisasi",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Autorizzazione non riuscita",
"callback.errorHint": "Torna alla pagina precedente e riprova.",
"callback.success": "Autorizzazione completata",
"callback.successHint": "Ora puoi chiudere questa scheda e tornare alla pagina precedente.",
"connect": "Connetti a",
"continue": "Continua",
"error.authAppInfoFetchFailed": "Impossibile recuperare le informazioni sull'app per l'autorizzazione",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "認証に失敗しました",
"callback.errorHint": "前のページに戻ってもう一度お試しください。",
"callback.success": "認証が完了しました",
"callback.successHint": "このタブを閉じて前のページに戻ることができます。",
"connect": "接続する",
"continue": "続行",
"error.authAppInfoFetchFailed": "認証のためのアプリ情報の取得に失敗しました",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "권한 부여 실패",
"callback.errorHint": "이전 페이지로 돌아가 다시 시도해 주세요.",
"callback.success": "권한 부여 완료",
"callback.successHint": "이제 이 탭을 닫고 이전 페이지로 돌아갈 수 있습니다.",
"connect": "연결",
"continue": "계속",
"error.authAppInfoFetchFailed": "인증을 위한 앱 정보를 가져오지 못했습니다.",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "ການອະນຸມັດລົ້ມເຫຼວ",
"callback.errorHint": "ກະລຸນາກັບໄປໜ້າກ່ອນ ແລະລອງອີກຄັ້ງ.",
"callback.success": "ການອະນຸມັດສຳເລັດແລ້ວ",
"callback.successHint": "ຕອນນີ້ທ່ານສາມາດປິດແທັບນີ້ ແລະກັບໄປໜ້າກ່ອນໄດ້.",
"connect": "ເຊື່ອມຕໍ່ກັບ",
"continue": "ດຳເນີນການຕໍ່",
"error.authAppInfoFetchFailed": "ບໍ່ສາມາດດຶງຂໍ້ມູນແອັບຯເພື່ອການອະນຸມັດໄດ້",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Autorisatie mislukt",
"callback.errorHint": "Ga terug naar de vorige pagina en probeer het opnieuw.",
"callback.success": "Autorisatie voltooid",
"callback.successHint": "Je kunt dit tabblad nu sluiten en teruggaan naar de vorige pagina.",
"connect": "Connect to",
"continue": "Continue",
"error.authAppInfoFetchFailed": "Failed to fetch app info for authorization",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Autoryzacja nie powiodła się",
"callback.errorHint": "Wróć do poprzedniej strony i spróbuj ponownie.",
"callback.success": "Autoryzacja zakończona",
"callback.successHint": "Możesz teraz zamknąć tę kartę i wrócić do poprzedniej strony.",
"connect": "Połącz z",
"continue": "Kontynuuj",
"error.authAppInfoFetchFailed": "Nie udało się pobrać informacji o aplikacji w celu autoryzacji",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Falha na autorização",
"callback.errorHint": "Volte à página anterior e tente novamente.",
"callback.success": "Autorização concluída",
"callback.successHint": "Agora você pode fechar esta aba e voltar à página anterior.",
"connect": "Conectar a",
"continue": "Continue",
"error.authAppInfoFetchFailed": "Falha ao buscar informações do aplicativo para autorização",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Autorizarea a eșuat",
"callback.errorHint": "Reveniți la pagina anterioară și încercați din nou.",
"callback.success": "Autorizare finalizată",
"callback.successHint": "Acum puteți închide această filă și reveni la pagina anterioară.",
"connect": "Conectează la",
"continue": "Continuați",
"error.authAppInfoFetchFailed": "Nu s-au putut obține informațiile aplicației pentru autorizare",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Авторизация не удалась",
"callback.errorHint": "Вернитесь на предыдущую страницу и попробуйте ещё раз.",
"callback.success": "Авторизация завершена",
"callback.successHint": "Теперь вы можете закрыть эту вкладку и вернуться на предыдущую страницу.",
"connect": "Подключиться к",
"continue": "Продолжить",
"error.authAppInfoFetchFailed": "Не удалось получить информацию об приложении для авторизации",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Avtorizacija ni uspela",
"callback.errorHint": "Vrnite se na prejšnjo stran in poskusite znova.",
"callback.success": "Avtorizacija je zaključena",
"callback.successHint": "Zdaj lahko zaprete ta zavihek in se vrnete na prejšnjo stran.",
"connect": "Poveži se z",
"continue": "Nadaljuj",
"error.authAppInfoFetchFailed": "Pridobivanje informacij o aplikaciji za avtorizacijo ni uspelo",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "การอนุญาตล้มเหลว",
"callback.errorHint": "โปรดกลับไปยังหน้าก่อนหน้าแล้วลองอีกครั้ง",
"callback.success": "การอนุญาตเสร็จสมบูรณ์",
"callback.successHint": "ตอนนี้คุณสามารถปิดแท็บนี้และกลับไปยังหน้าก่อนหน้าได้",
"connect": "เชื่อมต่อกับ",
"continue": "ดำเนินต่อไป",
"error.authAppInfoFetchFailed": "ไม่สามารถดึงข้อมูลแอปเพื่อการอนุญาตได้",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Yetkilendirme başarısız",
"callback.errorHint": "Lütfen önceki sayfaya dönüp tekrar deneyin.",
"callback.success": "Yetkilendirme tamamlandı",
"callback.successHint": "Artık bu sekmeyi kapatıp önceki sayfaya dönebilirsiniz.",
"connect": "Bağlan",
"continue": "Devam et",
"error.authAppInfoFetchFailed": "Yetkilendirme için uygulama bilgisi alınamadı",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Авторизація не вдалася",
"callback.errorHint": "Поверніться на попередню сторінку та спробуйте ще раз.",
"callback.success": "Авторизацію завершено",
"callback.successHint": "Тепер ви можете закрити цю вкладку та повернутися на попередню сторінку.",
"connect": "Підключитися до",
"continue": "Продовжувати",
"error.authAppInfoFetchFailed": "Не вдалося отримати інформацію про додаток для авторизації",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "Ủy quyền không thành công",
"callback.errorHint": "Vui lòng quay lại trang trước và thử lại.",
"callback.success": "Ủy quyền hoàn tất",
"callback.successHint": "Bây giờ bạn có thể đóng tab này và quay lại trang trước.",
"connect": "Kết nối với",
"continue": "Tiếp tục",
"error.authAppInfoFetchFailed": "Không thể lấy thông tin ứng dụng để xác thực",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "授权失败",
"callback.errorHint": "请返回上一页并重试。",
"callback.success": "授权完成",
"callback.successHint": "现在可以关闭此标签页并返回上一页。",
"connect": "连接到",
"continue": "继续",
"error.authAppInfoFetchFailed": "获取待授权应用的信息失败",
+4
View File
@@ -1,4 +1,8 @@
{
"callback.error": "授權失敗",
"callback.errorHint": "請返回上一頁並重試。",
"callback.success": "授權完成",
"callback.successHint": "現在可以關閉此分頁並返回上一頁。",
"connect": "連接到",
"continue": "繼續",
"error.authAppInfoFetchFailed": "無法獲取應用程式授權信息",
+4
View File
@@ -3467,6 +3467,10 @@ export default interface Resources {
'toolsPage.toolPlugin': 'Tool Plugin'
}
oauth: {
'callback.error': 'Authorization failed'
'callback.errorHint': 'Please return to the previous page and try again.'
'callback.success': 'Authorization complete'
'callback.successHint': 'You can now close this tab and return to the previous page.'
connect: 'Connect to'
continue: 'Continue'
'error.authAppInfoFetchFailed': 'Failed to fetch app info for authorization'