diff --git a/web/app/account/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx similarity index 100% rename from web/app/account/account-page/AvatarWithEdit.tsx rename to web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx diff --git a/web/app/account/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx similarity index 100% rename from web/app/account/account-page/email-change-modal.tsx rename to web/app/account/(commonLayout)/account-page/email-change-modal.tsx diff --git a/web/app/account/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx similarity index 100% rename from web/app/account/account-page/index.tsx rename to web/app/account/(commonLayout)/account-page/index.tsx diff --git a/web/app/account/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx similarity index 100% rename from web/app/account/avatar.tsx rename to web/app/account/(commonLayout)/avatar.tsx diff --git a/web/app/account/delete-account/components/check-email.tsx b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx similarity index 100% rename from web/app/account/delete-account/components/check-email.tsx rename to web/app/account/(commonLayout)/delete-account/components/check-email.tsx diff --git a/web/app/account/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx similarity index 100% rename from web/app/account/delete-account/components/feed-back.tsx rename to web/app/account/(commonLayout)/delete-account/components/feed-back.tsx diff --git a/web/app/account/delete-account/components/verify-email.tsx b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx similarity index 100% rename from web/app/account/delete-account/components/verify-email.tsx rename to web/app/account/(commonLayout)/delete-account/components/verify-email.tsx diff --git a/web/app/account/delete-account/index.tsx b/web/app/account/(commonLayout)/delete-account/index.tsx similarity index 100% rename from web/app/account/delete-account/index.tsx rename to web/app/account/(commonLayout)/delete-account/index.tsx diff --git a/web/app/account/delete-account/state.tsx b/web/app/account/(commonLayout)/delete-account/state.tsx similarity index 100% rename from web/app/account/delete-account/state.tsx rename to web/app/account/(commonLayout)/delete-account/state.tsx diff --git a/web/app/account/header.tsx b/web/app/account/(commonLayout)/header.tsx similarity index 97% rename from web/app/account/header.tsx rename to web/app/account/(commonLayout)/header.tsx index af09ca1c9c..ce804055b5 100644 --- a/web/app/account/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -2,11 +2,11 @@ import { useTranslation } from 'react-i18next' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' import { useRouter } from 'next/navigation' -import Button from '../components/base/button' -import Avatar from './avatar' +import Button from '@/app/components/base/button' import DifyLogo from '@/app/components/base/logo/dify-logo' import { useCallback } from 'react' import { useGlobalPublicStore } from '@/context/global-public-context' +import Avatar from './avatar' const Header = () => { const { t } = useTranslation() diff --git a/web/app/account/layout.tsx b/web/app/account/(commonLayout)/layout.tsx similarity index 100% rename from web/app/account/layout.tsx rename to web/app/account/(commonLayout)/layout.tsx diff --git a/web/app/account/page.tsx b/web/app/account/(commonLayout)/page.tsx similarity index 100% rename from web/app/account/page.tsx rename to web/app/account/(commonLayout)/page.tsx diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx new file mode 100644 index 0000000000..702c48d683 --- /dev/null +++ b/web/app/account/oauth/authorize/layout.tsx @@ -0,0 +1,38 @@ +'use client' +import Header from '@/app/signin/_header' + +import cn from '@/utils/classnames' +import { useGlobalPublicStore } from '@/context/global-public-context' +import useDocumentTitle from '@/hooks/use-document-title' +import { useEffect, useState } from 'react' +import { AppContextProvider } from '@/context/app-context' + +export default function SignInLayout({ children }: any) { + const { systemFeatures } = useGlobalPublicStore() + useDocumentTitle('') + const [isLoggedIn, setIsLoggedIn] = useState(false) + useEffect(() => { + setIsLoggedIn(!!localStorage.getItem('console_token')) + }, []) + return <> +
+
+
+
+
+ { + isLoggedIn + ? + {children} + + : children + } +
+
+ {systemFeatures.branding.enabled === false &&
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
} +
+
+ +} diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx new file mode 100644 index 0000000000..894834bc8c --- /dev/null +++ b/web/app/account/oauth/authorize/page.tsx @@ -0,0 +1,185 @@ +'use client' + +import React, { useMemo } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import cn from '@/utils/classnames' +import Button from '@/app/components/base/button' +import Avatar from '@/app/components/base/avatar' +import { useAppContext } from '@/context/app-context' +import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth-provider' +import Loading from '@/app/components/base/loading' +import { + RiAccountCircleLine, + RiGlobalLine, + RiInfoCardLine, + RiMailLine, + RiTranslate2, +} from '@remixicon/react' + +const SCOPE_ICON_MAP: Record, label: string }> = { + 'read:name': { + icon: RiInfoCardLine, + label: 'Name', + }, + 'read:email': { + icon: RiMailLine, + label: 'Email', + }, + 'read:avatar': { + icon: RiAccountCircleLine, + label: 'Avatar', + }, + 'read:interface_language': { + icon: RiTranslate2, + label: 'Language Preference', + }, + 'read:timezone': { + icon: RiGlobalLine, + label: 'Timezone', + }, +} + +const STORAGE_KEY = 'oauth_authorize_pending' + +function buildReturnUrl(pathname: string, search: string) { + try { + const base = `${globalThis.location.origin}${pathname}${search}` + return base + } + catch { + return pathname + search + } +} + +export default function OAuthAuthorizePage() { + const router = useRouter() + const searchParams = useSearchParams() + const client_id = searchParams.get('client_id') || '' + const redirect_uri = searchParams.get('redirect_uri') || '' + const response_type = searchParams.get('response_type') || 'code' + + const { userProfile } = useAppContext() + const { data: authAppInfo, isLoading, isError, error } = useOAuthAppInfo(client_id, redirect_uri, true) + const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() + + const isLoggedIn = useMemo(() => { + try { + return Boolean(localStorage.getItem('console_token')) + } + catch { return false } + }, []) + + const invalidParams = !client_id || !redirect_uri || response_type !== 'code' + + const onLoginClick = () => { + try { + const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) + localStorage.setItem(STORAGE_KEY, JSON.stringify({ client_id, redirect_uri, returnUrl })) + router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`) + } + catch { + router.push('/signin') + } + } + + const onAuthorize = async () => { + if (!client_id || !redirect_uri) + return + try { + const { code } = await authorize({ client_id }) + const url = new URL(redirect_uri) + url.searchParams.set('code', code) + globalThis.location.href = url.toString() + } + catch { + // handled by global toast + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (invalidParams || isError) { + return ( +
+

{(error as any)?.message || 'Invalid parameters'}

+
+ ) + } + + return ( +
+ {authAppInfo?.app_icon && ( +
+ {/* app icon */} + app icon +
+ )} + +
+
+ {isLoggedIn &&
Connect to
} +
{authAppInfo?.app_label?.en_US || authAppInfo?.app_label?.zh_Hans || authAppInfo?.app_label?.ja_JP}
+ {!isLoggedIn &&
wants to access your Dify Cloud account
} +
+
{isLoggedIn ? `${authAppInfo?.app_label?.en_US} wants to access your Dify account` : 'Please log in to authorize'}
+
+ + {isLoggedIn && ( +
+
+ +
+
{userProfile.name}
+
{userProfile.email}
+
+
+ +
+ )} + + {isLoggedIn && Boolean(authAppInfo?.scope) && ( +
+ {authAppInfo!.scope.split(/\s+/).filter(Boolean).map((scope: string) => { + const Icon = SCOPE_ICON_MAP[scope] + return ( +
+ {Icon ? : } + {Icon.label} +
+ ) + })} +
+ )} + +
+ {!isLoggedIn ? ( + + ) : ( + <> + + + + )} +
+
+ + + + + + + + + + +
+
We respect your privacy and will only use this information to enhance your experience with our developer tools.
+
+ ) +} diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 51046fbd06..3ba5c0fafb 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -21,6 +21,7 @@ const NormalForm = () => { const searchParams = useSearchParams() const consoleToken = decodeURIComponent(searchParams.get('access_token') || '') const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '') + const redirectUrl = searchParams.get('redirect_url') || '' const message = decodeURIComponent(searchParams.get('message') || '') const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') const [isLoading, setIsLoading] = useState(true) @@ -37,6 +38,22 @@ const NormalForm = () => { if (consoleToken && refreshToken) { localStorage.setItem('console_token', consoleToken) localStorage.setItem('refresh_token', refreshToken) + const pendingStr = localStorage.getItem('oauth_authorize_pending') + if (redirectUrl) { + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (pendingStr) { + try { + const pending = JSON.parse(pendingStr) + if (pending?.returnUrl) { + localStorage.removeItem('oauth_authorize_pending') + router.replace(pending.returnUrl) + return + } + } + catch {} + } router.replace('/apps') return } diff --git a/web/service/oauth-provider.ts b/web/service/oauth-provider.ts new file mode 100644 index 0000000000..b5bfb058fd --- /dev/null +++ b/web/service/oauth-provider.ts @@ -0,0 +1,23 @@ +import { post } from './base' + +export type OAuthAppInfo = { + app_icon: string + app_label: Record + scope: string +} + +export type OAuthAuthorizeResponse = { + code: string +} + +export async function fetchOAuthAppInfo(client_id: string, redirect_uri: string) { + return post('/oauth/provider', { + body: { client_id, redirect_uri }, + }, { silent: true }) +} + +export async function authorizeOAuthApp(client_id: string) { + return post('/oauth/provider/authorize', { + body: { client_id }, + }) +} diff --git a/web/service/use-oauth-provider.ts b/web/service/use-oauth-provider.ts new file mode 100644 index 0000000000..fcca104b42 --- /dev/null +++ b/web/service/use-oauth-provider.ts @@ -0,0 +1,29 @@ +import { post } from './base' +import { useMutation, useQuery } from '@tanstack/react-query' + +const NAME_SPACE = 'oauth-provider' + +export type OAuthAppInfo = { + app_icon: string + app_label: Record + scope: string +} + +export type OAuthAuthorizeResponse = { + code: string +} + +export const useOAuthAppInfo = (client_id: string, redirect_uri: string, enabled = true) => { + return useQuery({ + queryKey: [NAME_SPACE, 'authAppInfo', client_id, redirect_uri], + queryFn: () => post('/oauth/provider', { body: { client_id, redirect_uri } }, { silent: true }), + enabled: Boolean(enabled && client_id && redirect_uri), + }) +} + +export const useAuthorizeOAuthApp = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'authorize'], + mutationFn: (payload: { client_id: string }) => post('/oauth/provider/authorize', { body: payload }), + }) +}