You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

page.tsx 7.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. 'use client'
  2. import React, { useEffect, useMemo, useRef } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import { useRouter, useSearchParams } from 'next/navigation'
  5. import Button from '@/app/components/base/button'
  6. import Avatar from '@/app/components/base/avatar'
  7. import Loading from '@/app/components/base/loading'
  8. import Toast from '@/app/components/base/toast'
  9. import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
  10. import { useAppContext } from '@/context/app-context'
  11. import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
  12. import {
  13. RiAccountCircleLine,
  14. RiGlobalLine,
  15. RiInfoCardLine,
  16. RiMailLine,
  17. RiTranslate2,
  18. } from '@remixicon/react'
  19. import dayjs from 'dayjs'
  20. export const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending'
  21. export const REDIRECT_URL_KEY = 'oauth_redirect_url'
  22. const OAUTH_AUTHORIZE_PENDING_TTL = 60 * 3
  23. function setItemWithExpiry(key: string, value: string, ttl: number) {
  24. const item = {
  25. value,
  26. expiry: dayjs().add(ttl, 'seconds').unix(),
  27. }
  28. localStorage.setItem(key, JSON.stringify(item))
  29. }
  30. function buildReturnUrl(pathname: string, search: string) {
  31. try {
  32. const base = `${globalThis.location.origin}${pathname}${search}`
  33. return base
  34. }
  35. catch {
  36. return pathname + search
  37. }
  38. }
  39. export default function OAuthAuthorize() {
  40. const { t } = useTranslation()
  41. const SCOPE_INFO_MAP: Record<string, { icon: React.ComponentType<{ className?: string }>, label: string }> = {
  42. 'read:name': {
  43. icon: RiInfoCardLine,
  44. label: t('oauth.scopes.name'),
  45. },
  46. 'read:email': {
  47. icon: RiMailLine,
  48. label: t('oauth.scopes.email'),
  49. },
  50. 'read:avatar': {
  51. icon: RiAccountCircleLine,
  52. label: t('oauth.scopes.avatar'),
  53. },
  54. 'read:interface_language': {
  55. icon: RiTranslate2,
  56. label: t('oauth.scopes.languagePreference'),
  57. },
  58. 'read:timezone': {
  59. icon: RiGlobalLine,
  60. label: t('oauth.scopes.timezone'),
  61. },
  62. }
  63. const router = useRouter()
  64. const language = useLanguage()
  65. const searchParams = useSearchParams()
  66. const client_id = decodeURIComponent(searchParams.get('client_id') || '')
  67. const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
  68. const { userProfile } = useAppContext()
  69. const { data: authAppInfo, isLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
  70. const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
  71. const hasNotifiedRef = useRef(false)
  72. const isLoggedIn = useMemo(() => {
  73. try {
  74. return Boolean(localStorage.getItem('console_token') && localStorage.getItem('refresh_token'))
  75. }
  76. catch { return false }
  77. }, [])
  78. const onLoginSwitchClick = () => {
  79. try {
  80. const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
  81. setItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
  82. router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`)
  83. }
  84. catch {
  85. router.push('/signin')
  86. }
  87. }
  88. const onAuthorize = async () => {
  89. if (!client_id || !redirect_uri)
  90. return
  91. try {
  92. const { code } = await authorize({ client_id })
  93. const url = new URL(redirect_uri)
  94. url.searchParams.set('code', code)
  95. globalThis.location.href = url.toString()
  96. }
  97. catch (err: any) {
  98. Toast.notify({
  99. type: 'error',
  100. message: `${t('oauth.error.authorizeFailed')}: ${err.message}`,
  101. })
  102. }
  103. }
  104. useEffect(() => {
  105. const invalidParams = !client_id || !redirect_uri
  106. if ((invalidParams || isError) && !hasNotifiedRef.current) {
  107. hasNotifiedRef.current = true
  108. Toast.notify({
  109. type: 'error',
  110. message: invalidParams ? t('oauth.error.invalidParams') : t('oauth.error.authAppInfoFetchFailed'),
  111. duration: 0,
  112. })
  113. }
  114. }, [client_id, redirect_uri, isError])
  115. if (isLoading) {
  116. return (
  117. <div className='bg-background-default-subtle'>
  118. <Loading type='app' />
  119. </div>
  120. )
  121. }
  122. return (
  123. <div className='bg-background-default-subtle'>
  124. {authAppInfo?.app_icon && (
  125. <div className='w-max rounded-2xl border-[0.5px] border-components-panel-border bg-text-primary-on-surface p-3 shadow-lg'>
  126. <img src={authAppInfo.app_icon} alt='app icon' className='h-10 w-10 rounded' />
  127. </div>
  128. )}
  129. <div className={`mb-4 mt-5 flex flex-col gap-2 ${isLoggedIn ? 'pb-2' : ''}`}>
  130. <div className='title-4xl-semi-bold'>
  131. {isLoggedIn && <div className='text-text-primary'>{t('oauth.connect')}</div>}
  132. <div className='text-[var(--color-saas-dify-blue-inverted)]'>{authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t('oauth.unknownApp')}</div>
  133. {!isLoggedIn && <div className='text-text-primary'>{t('oauth.tips.notLoggedIn')}</div>}
  134. </div>
  135. <div className='body-md-regular text-text-secondary'>{isLoggedIn ? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t('oauth.unknownApp')} ${t('oauth.tips.loggedIn')}` : t('oauth.tips.needLogin')}</div>
  136. </div>
  137. {isLoggedIn && userProfile && (
  138. <div className='flex items-center justify-between rounded-xl bg-background-section-burn-inverted p-3'>
  139. <div className='flex items-center gap-2.5'>
  140. <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
  141. <div>
  142. <div className='system-md-semi-bold text-text-secondary'>{userProfile.name}</div>
  143. <div className='system-xs-regular text-text-tertiary'>{userProfile.email}</div>
  144. </div>
  145. </div>
  146. <Button variant='tertiary' size='small' onClick={onLoginSwitchClick}>{t('oauth.switchAccount')}</Button>
  147. </div>
  148. )}
  149. {isLoggedIn && Boolean(authAppInfo?.scope) && (
  150. <div className='mt-2 flex flex-col gap-2.5 rounded-xl bg-background-section-burn-inverted px-[22px] py-5 text-text-secondary'>
  151. {authAppInfo!.scope.split(/\s+/).filter(Boolean).map((scope: string) => {
  152. const Icon = SCOPE_INFO_MAP[scope]
  153. return (
  154. <div key={scope} className='body-sm-medium flex items-center gap-2 text-text-secondary'>
  155. {Icon ? <Icon.icon className='h-4 w-4' /> : <RiAccountCircleLine className='h-4 w-4' />}
  156. {Icon.label}
  157. </div>
  158. )
  159. })}
  160. </div>
  161. )}
  162. <div className='flex flex-col items-center gap-2 pt-4'>
  163. {!isLoggedIn ? (
  164. <Button variant='primary' size='large' className='w-full' onClick={onLoginSwitchClick}>{t('oauth.login')}</Button>
  165. ) : (
  166. <>
  167. <Button variant='primary' size='large' className='w-full' onClick={onAuthorize} disabled={!client_id || !redirect_uri || isError || authorizing} loading={authorizing}>{t('oauth.continue')}</Button>
  168. <Button size='large' className='w-full' onClick={() => router.push('/apps')}>{t('common.operation.cancel')}</Button>
  169. </>
  170. )}
  171. </div>
  172. <div className='mt-4 py-2'>
  173. <svg xmlns="http://www.w3.org/2000/svg" width="400" height="1" viewBox="0 0 400 1" fill="none">
  174. <path d="M0 0.5H400" stroke="url(#paint0_linear_2_5904)" />
  175. <defs>
  176. <linearGradient id="paint0_linear_2_5904" x1="400" y1="9.49584" x2="0.000228929" y2="9.17666" gradientUnits="userSpaceOnUse">
  177. <stop stop-color="white" stop-opacity="0.01" />
  178. <stop offset="0.505" stop-color="#101828" stop-opacity="0.08" />
  179. <stop offset="1" stop-color="white" stop-opacity="0.01" />
  180. </linearGradient>
  181. </defs>
  182. </svg>
  183. </div>
  184. <div className='system-xs-regular mt-3 text-text-tertiary'>{t('oauth.tips.common')}</div>
  185. </div>
  186. )
  187. }