| @@ -4,7 +4,7 @@ import { useContext, useContextSelector } from 'use-context-selector' | |||
| import { useRouter } from 'next/navigation' | |||
| import { useCallback, useEffect, useMemo, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react' | |||
| import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' | |||
| import cn from '@/utils/classnames' | |||
| import type { App } from '@/types/app' | |||
| import Confirm from '@/app/components/base/confirm' | |||
| @@ -338,7 +338,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| </div> | |||
| <div className='flex h-5 w-5 shrink-0 items-center justify-center'> | |||
| {app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}> | |||
| <RiGlobalLine className='h-4 w-4 text-text-accent' /> | |||
| <RiGlobalLine className='h-4 w-4 text-text-quaternary' /> | |||
| </Tooltip>} | |||
| {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}> | |||
| <RiLockLine className='h-4 w-4 text-text-quaternary' /> | |||
| @@ -346,6 +346,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { | |||
| {app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}> | |||
| <RiBuildingLine className='h-4 w-4 text-text-quaternary' /> | |||
| </Tooltip>} | |||
| {app.access_mode === AccessMode.EXTERNAL_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.external')}> | |||
| <RiVerifiedBadgeLine className='h-4 w-4 text-text-quaternary' /> | |||
| </Tooltip>} | |||
| </div> | |||
| </div> | |||
| <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> | |||
| @@ -1,14 +1,42 @@ | |||
| import React from 'react' | |||
| 'use client' | |||
| import React, { useEffect, useState } from 'react' | |||
| import type { FC } from 'react' | |||
| import type { Metadata } from 'next' | |||
| export const metadata: Metadata = { | |||
| icons: 'data:,', // prevent browser from using default favicon | |||
| } | |||
| import { usePathname, useSearchParams } from 'next/navigation' | |||
| import Loading from '../components/base/loading' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { AccessMode } from '@/models/access-control' | |||
| import { getAppAccessModeByAppCode } from '@/service/share' | |||
| const Layout: FC<{ | |||
| children: React.ReactNode | |||
| }> = ({ children }) => { | |||
| const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) | |||
| const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) | |||
| const pathname = usePathname() | |||
| const searchParams = useSearchParams() | |||
| const redirectUrl = searchParams.get('redirect_url') | |||
| const [isLoading, setIsLoading] = useState(true) | |||
| useEffect(() => { | |||
| (async () => { | |||
| let appCode: string | null = null | |||
| if (redirectUrl) | |||
| appCode = redirectUrl?.split('/').pop() || null | |||
| else | |||
| appCode = pathname.split('/').pop() || null | |||
| if (!appCode) | |||
| return | |||
| setIsLoading(true) | |||
| const ret = await getAppAccessModeByAppCode(appCode) | |||
| setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC) | |||
| setIsLoading(false) | |||
| })() | |||
| }, [pathname, redirectUrl, setWebAppAccessMode]) | |||
| if (isLoading || isGlobalPending) { | |||
| return <div className='flex h-full w-full items-center justify-center'> | |||
| <Loading /> | |||
| </div> | |||
| } | |||
| return ( | |||
| <div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]"> | |||
| {children} | |||
| @@ -0,0 +1,96 @@ | |||
| 'use client' | |||
| import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useState } from 'react' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { useContext } from 'use-context-selector' | |||
| import Countdown from '@/app/components/signin/countdown' | |||
| import Button from '@/app/components/base/button' | |||
| import Input from '@/app/components/base/input' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' | |||
| import I18NContext from '@/context/i18n' | |||
| export default function CheckCode() { | |||
| const { t } = useTranslation() | |||
| const router = useRouter() | |||
| const searchParams = useSearchParams() | |||
| const email = decodeURIComponent(searchParams.get('email') as string) | |||
| const token = decodeURIComponent(searchParams.get('token') as string) | |||
| const [code, setVerifyCode] = useState('') | |||
| const [loading, setIsLoading] = useState(false) | |||
| const { locale } = useContext(I18NContext) | |||
| const verify = async () => { | |||
| try { | |||
| if (!code.trim()) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.checkCode.emptyCode'), | |||
| }) | |||
| return | |||
| } | |||
| if (!/\d{6}/.test(code)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.checkCode.invalidCode'), | |||
| }) | |||
| return | |||
| } | |||
| setIsLoading(true) | |||
| const ret = await verifyWebAppResetPasswordCode({ email, code, token }) | |||
| if (ret.is_valid) { | |||
| const params = new URLSearchParams(searchParams) | |||
| params.set('token', encodeURIComponent(ret.token)) | |||
| router.push(`/webapp-reset-password/set-password?${params.toString()}`) | |||
| } | |||
| } | |||
| catch (error) { console.error(error) } | |||
| finally { | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| const resendCode = async () => { | |||
| try { | |||
| const res = await sendWebAppResetPasswordCode(email, locale) | |||
| if (res.result === 'success') { | |||
| const params = new URLSearchParams(searchParams) | |||
| params.set('token', encodeURIComponent(res.data)) | |||
| router.replace(`/webapp-reset-password/check-code?${params.toString()}`) | |||
| } | |||
| } | |||
| catch (error) { console.error(error) } | |||
| } | |||
| return <div className='flex flex-col gap-3'> | |||
| <div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg'> | |||
| <RiMailSendFill className='h-6 w-6 text-2xl' /> | |||
| </div> | |||
| <div className='pb-4 pt-2'> | |||
| <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2> | |||
| <p className='body-md-regular mt-2 text-text-secondary'> | |||
| <span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span> | |||
| <br /> | |||
| {t('login.checkCode.validTime')} | |||
| </p> | |||
| </div> | |||
| <form action=""> | |||
| <input type='text' className='hidden' /> | |||
| <label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label> | |||
| <Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> | |||
| <Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button> | |||
| <Countdown onResend={resendCode} /> | |||
| </form> | |||
| <div className='py-2'> | |||
| <div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| <div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> | |||
| <div className='bg-background-default-dimm inline-block rounded-full p-1'> | |||
| <RiArrowLeftLine size={12} /> | |||
| </div> | |||
| <span className='system-xs-regular ml-2'>{t('login.back')}</span> | |||
| </div> | |||
| </div> | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| 'use client' | |||
| import Header from '@/app/signin/_header' | |||
| import cn from '@/utils/classnames' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| export default function SignInLayout({ children }: any) { | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| return <> | |||
| <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> | |||
| <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> | |||
| <Header /> | |||
| <div className={ | |||
| cn( | |||
| 'flex w-full grow flex-col items-center justify-center', | |||
| 'px-6', | |||
| 'md:px-[108px]', | |||
| ) | |||
| }> | |||
| <div className='flex w-[400px] flex-col'> | |||
| {children} | |||
| </div> | |||
| </div> | |||
| {!systemFeatures.branding.enabled && <div className='system-xs-regular px-8 py-6 text-text-tertiary'> | |||
| © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. | |||
| </div>} | |||
| </div> | |||
| </div> | |||
| </> | |||
| } | |||
| @@ -0,0 +1,104 @@ | |||
| 'use client' | |||
| import Link from 'next/link' | |||
| import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useState } from 'react' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { useContext } from 'use-context-selector' | |||
| import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' | |||
| import { emailRegex } from '@/config' | |||
| import Button from '@/app/components/base/button' | |||
| import Input from '@/app/components/base/input' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { sendResetPasswordCode } from '@/service/common' | |||
| import I18NContext from '@/context/i18n' | |||
| import { noop } from 'lodash-es' | |||
| import useDocumentTitle from '@/hooks/use-document-title' | |||
| export default function CheckCode() { | |||
| const { t } = useTranslation() | |||
| useDocumentTitle('') | |||
| const searchParams = useSearchParams() | |||
| const router = useRouter() | |||
| const [email, setEmail] = useState('') | |||
| const [loading, setIsLoading] = useState(false) | |||
| const { locale } = useContext(I18NContext) | |||
| const handleGetEMailVerificationCode = async () => { | |||
| try { | |||
| if (!email) { | |||
| Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) | |||
| return | |||
| } | |||
| if (!emailRegex.test(email)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.emailInValid'), | |||
| }) | |||
| return | |||
| } | |||
| setIsLoading(true) | |||
| const res = await sendResetPasswordCode(email, locale) | |||
| if (res.result === 'success') { | |||
| localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) | |||
| const params = new URLSearchParams(searchParams) | |||
| params.set('token', encodeURIComponent(res.data)) | |||
| params.set('email', encodeURIComponent(email)) | |||
| router.push(`/webapp-reset-password/check-code?${params.toString()}`) | |||
| } | |||
| else if (res.code === 'account_not_found') { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.registrationNotAllowed'), | |||
| }) | |||
| } | |||
| else { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: res.data, | |||
| }) | |||
| } | |||
| } | |||
| catch (error) { | |||
| console.error(error) | |||
| } | |||
| finally { | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| return <div className='flex flex-col gap-3'> | |||
| <div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'> | |||
| <RiLockPasswordLine className='h-6 w-6 text-2xl text-text-accent-light-mode-only' /> | |||
| </div> | |||
| <div className='pb-4 pt-2'> | |||
| <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2> | |||
| <p className='body-md-regular mt-2 text-text-secondary'> | |||
| {t('login.resetPasswordDesc')} | |||
| </p> | |||
| </div> | |||
| <form onSubmit={noop}> | |||
| <input type='text' className='hidden' /> | |||
| <div className='mb-2'> | |||
| <label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label> | |||
| <div className='mt-1'> | |||
| <Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} /> | |||
| </div> | |||
| <div className='mt-3'> | |||
| <Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| <div className='py-2'> | |||
| <div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| <Link href={`/webapp-signin?${searchParams.toString()}`} className='flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary'> | |||
| <div className='inline-block rounded-full bg-background-default-dimmed p-1'> | |||
| <RiArrowLeftLine size={12} /> | |||
| </div> | |||
| <span className='system-xs-regular ml-2'>{t('login.backToLogin')}</span> | |||
| </Link> | |||
| </div> | |||
| } | |||
| @@ -0,0 +1,188 @@ | |||
| 'use client' | |||
| import { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import cn from 'classnames' | |||
| import { RiCheckboxCircleFill } from '@remixicon/react' | |||
| import { useCountDown } from 'ahooks' | |||
| import Button from '@/app/components/base/button' | |||
| import { changeWebAppPasswordWithToken } from '@/service/common' | |||
| import Toast from '@/app/components/base/toast' | |||
| import Input from '@/app/components/base/input' | |||
| const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ | |||
| const ChangePasswordForm = () => { | |||
| const { t } = useTranslation() | |||
| const router = useRouter() | |||
| const searchParams = useSearchParams() | |||
| const token = decodeURIComponent(searchParams.get('token') || '') | |||
| const [password, setPassword] = useState('') | |||
| const [confirmPassword, setConfirmPassword] = useState('') | |||
| const [showSuccess, setShowSuccess] = useState(false) | |||
| const [showPassword, setShowPassword] = useState(false) | |||
| const [showConfirmPassword, setShowConfirmPassword] = useState(false) | |||
| const showErrorMessage = useCallback((message: string) => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message, | |||
| }) | |||
| }, []) | |||
| const getSignInUrl = () => { | |||
| return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}` | |||
| } | |||
| const AUTO_REDIRECT_TIME = 5000 | |||
| const [leftTime, setLeftTime] = useState<number | undefined>(undefined) | |||
| const [countdown] = useCountDown({ | |||
| leftTime, | |||
| onEnd: () => { | |||
| router.replace(getSignInUrl()) | |||
| }, | |||
| }) | |||
| const valid = useCallback(() => { | |||
| if (!password.trim()) { | |||
| showErrorMessage(t('login.error.passwordEmpty')) | |||
| return false | |||
| } | |||
| if (!validPassword.test(password)) { | |||
| showErrorMessage(t('login.error.passwordInvalid')) | |||
| return false | |||
| } | |||
| if (password !== confirmPassword) { | |||
| showErrorMessage(t('common.account.notEqual')) | |||
| return false | |||
| } | |||
| return true | |||
| }, [password, confirmPassword, showErrorMessage, t]) | |||
| const handleChangePassword = useCallback(async () => { | |||
| if (!valid()) | |||
| return | |||
| try { | |||
| await changeWebAppPasswordWithToken({ | |||
| url: '/forgot-password/resets', | |||
| body: { | |||
| token, | |||
| new_password: password, | |||
| password_confirm: confirmPassword, | |||
| }, | |||
| }) | |||
| setShowSuccess(true) | |||
| setLeftTime(AUTO_REDIRECT_TIME) | |||
| } | |||
| catch (error) { | |||
| console.error(error) | |||
| } | |||
| }, [password, token, valid, confirmPassword]) | |||
| return ( | |||
| <div className={ | |||
| cn( | |||
| 'flex w-full grow flex-col items-center justify-center', | |||
| 'px-6', | |||
| 'md:px-[108px]', | |||
| ) | |||
| }> | |||
| {!showSuccess && ( | |||
| <div className='flex flex-col md:w-[400px]'> | |||
| <div className="mx-auto w-full"> | |||
| <h2 className="title-4xl-semi-bold text-text-primary"> | |||
| {t('login.changePassword')} | |||
| </h2> | |||
| <p className='body-md-regular mt-2 text-text-secondary'> | |||
| {t('login.changePasswordTip')} | |||
| </p> | |||
| </div> | |||
| <div className="mx-auto mt-6 w-full"> | |||
| <div className="bg-white"> | |||
| {/* Password */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="password" className="system-md-semibold my-2 text-text-secondary"> | |||
| {t('common.account.newPassword')} | |||
| </label> | |||
| <div className='relative mt-1'> | |||
| <Input | |||
| id="password" type={showPassword ? 'text' : 'password'} | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowPassword(!showPassword)} | |||
| > | |||
| {showPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| <div className='body-xs-regular mt-1 text-text-secondary'>{t('login.error.passwordInvalid')}</div> | |||
| </div> | |||
| {/* Confirm Password */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="confirmPassword" className="system-md-semibold my-2 text-text-secondary"> | |||
| {t('common.account.confirmPassword')} | |||
| </label> | |||
| <div className='relative mt-1'> | |||
| <Input | |||
| id="confirmPassword" | |||
| type={showConfirmPassword ? 'text' : 'password'} | |||
| value={confirmPassword} | |||
| onChange={e => setConfirmPassword(e.target.value)} | |||
| placeholder={t('login.confirmPasswordPlaceholder') || ''} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowConfirmPassword(!showConfirmPassword)} | |||
| > | |||
| {showConfirmPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div> | |||
| <Button | |||
| variant='primary' | |||
| className='w-full' | |||
| onClick={handleChangePassword} | |||
| > | |||
| {t('login.changePasswordBtn')} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {showSuccess && ( | |||
| <div className="flex flex-col md:w-[400px]"> | |||
| <div className="mx-auto w-full"> | |||
| <div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg"> | |||
| <RiCheckboxCircleFill className='h-6 w-6 text-text-success' /> | |||
| </div> | |||
| <h2 className="title-4xl-semi-bold text-text-primary"> | |||
| {t('login.passwordChangedTip')} | |||
| </h2> | |||
| </div> | |||
| <div className="mx-auto mt-6 w-full"> | |||
| <Button variant='primary' className='w-full' onClick={() => { | |||
| setLeftTime(undefined) | |||
| router.replace(getSignInUrl()) | |||
| }}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| export default ChangePasswordForm | |||
| @@ -0,0 +1,115 @@ | |||
| 'use client' | |||
| import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useCallback, useState } from 'react' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { useContext } from 'use-context-selector' | |||
| import Countdown from '@/app/components/signin/countdown' | |||
| import Button from '@/app/components/base/button' | |||
| import Input from '@/app/components/base/input' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' | |||
| import I18NContext from '@/context/i18n' | |||
| import { setAccessToken } from '@/app/components/share/utils' | |||
| import { fetchAccessToken } from '@/service/share' | |||
| export default function CheckCode() { | |||
| const { t } = useTranslation() | |||
| const router = useRouter() | |||
| const searchParams = useSearchParams() | |||
| const email = decodeURIComponent(searchParams.get('email') as string) | |||
| const token = decodeURIComponent(searchParams.get('token') as string) | |||
| const [code, setVerifyCode] = useState('') | |||
| const [loading, setIsLoading] = useState(false) | |||
| const { locale } = useContext(I18NContext) | |||
| const redirectUrl = searchParams.get('redirect_url') | |||
| const getAppCodeFromRedirectUrl = useCallback(() => { | |||
| const appCode = redirectUrl?.split('/').pop() | |||
| if (!appCode) | |||
| return null | |||
| return appCode | |||
| }, [redirectUrl]) | |||
| const verify = async () => { | |||
| try { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!code.trim()) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.checkCode.emptyCode'), | |||
| }) | |||
| return | |||
| } | |||
| if (!/\d{6}/.test(code)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.checkCode.invalidCode'), | |||
| }) | |||
| return | |||
| } | |||
| if (!redirectUrl || !appCode) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.redirectUrlMissing'), | |||
| }) | |||
| return | |||
| } | |||
| setIsLoading(true) | |||
| const ret = await webAppEmailLoginWithCode({ email, code, token }) | |||
| if (ret.result === 'success') { | |||
| localStorage.setItem('webapp_access_token', ret.data.access_token) | |||
| const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token }) | |||
| await setAccessToken(appCode, tokenResp.access_token) | |||
| router.replace(redirectUrl) | |||
| } | |||
| } | |||
| catch (error) { console.error(error) } | |||
| finally { | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| const resendCode = async () => { | |||
| try { | |||
| const ret = await sendWebAppEMailLoginCode(email, locale) | |||
| if (ret.result === 'success') { | |||
| const params = new URLSearchParams(searchParams) | |||
| params.set('token', encodeURIComponent(ret.data)) | |||
| router.replace(`/webapp-signin/check-code?${params.toString()}`) | |||
| } | |||
| } | |||
| catch (error) { console.error(error) } | |||
| } | |||
| return <div className='flex w-[400px] flex-col gap-3'> | |||
| <div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'> | |||
| <RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' /> | |||
| </div> | |||
| <div className='pb-4 pt-2'> | |||
| <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2> | |||
| <p className='body-md-regular mt-2 text-text-secondary'> | |||
| <span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span> | |||
| <br /> | |||
| {t('login.checkCode.validTime')} | |||
| </p> | |||
| </div> | |||
| <form action=""> | |||
| <label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label> | |||
| <Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> | |||
| <Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button> | |||
| <Countdown onResend={resendCode} /> | |||
| </form> | |||
| <div className='py-2'> | |||
| <div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| <div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'> | |||
| <div className='bg-background-default-dimm inline-block rounded-full p-1'> | |||
| <RiArrowLeftLine size={12} /> | |||
| </div> | |||
| <span className='system-xs-regular ml-2'>{t('login.back')}</span> | |||
| </div> | |||
| </div> | |||
| } | |||
| @@ -0,0 +1,80 @@ | |||
| 'use client' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import React, { useCallback, useEffect } from 'react' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { SSOProtocol } from '@/types/feature' | |||
| import Loading from '@/app/components/base/loading' | |||
| import AppUnavailable from '@/app/components/base/app-unavailable' | |||
| const ExternalMemberSSOAuth = () => { | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const searchParams = useSearchParams() | |||
| const router = useRouter() | |||
| const redirectUrl = searchParams.get('redirect_url') | |||
| const showErrorToast = (message: string) => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message, | |||
| }) | |||
| } | |||
| const getAppCodeFromRedirectUrl = useCallback(() => { | |||
| const appCode = redirectUrl?.split('/').pop() | |||
| if (!appCode) | |||
| return null | |||
| return appCode | |||
| }, [redirectUrl]) | |||
| const handleSSOLogin = useCallback(async () => { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!appCode || !redirectUrl) { | |||
| showErrorToast('redirect url or app code is invalid.') | |||
| return | |||
| } | |||
| switch (systemFeatures.webapp_auth.sso_config.protocol) { | |||
| case SSOProtocol.SAML: { | |||
| const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) | |||
| router.push(samlRes.url) | |||
| break | |||
| } | |||
| case SSOProtocol.OIDC: { | |||
| const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) | |||
| router.push(oidcRes.url) | |||
| break | |||
| } | |||
| case SSOProtocol.OAuth2: { | |||
| const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) | |||
| router.push(oauth2Res.url) | |||
| break | |||
| } | |||
| case '': | |||
| break | |||
| default: | |||
| showErrorToast('SSO protocol is not supported.') | |||
| } | |||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) | |||
| useEffect(() => { | |||
| handleSSOLogin() | |||
| }, [handleSSOLogin]) | |||
| if (!systemFeatures.webapp_auth.sso_config.protocol) { | |||
| return <div className="flex h-full items-center justify-center"> | |||
| <AppUnavailable code={403} unknownReason='sso protocol is invalid.' /> | |||
| </div> | |||
| } | |||
| return ( | |||
| <div className="flex h-full items-center justify-center"> | |||
| <Loading /> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(ExternalMemberSSOAuth) | |||
| @@ -0,0 +1,68 @@ | |||
| import { useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { useContext } from 'use-context-selector' | |||
| import Input from '@/app/components/base/input' | |||
| import Button from '@/app/components/base/button' | |||
| import { emailRegex } from '@/config' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { sendWebAppEMailLoginCode } from '@/service/common' | |||
| import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' | |||
| import I18NContext from '@/context/i18n' | |||
| import { noop } from 'lodash-es' | |||
| export default function MailAndCodeAuth() { | |||
| const { t } = useTranslation() | |||
| const router = useRouter() | |||
| const searchParams = useSearchParams() | |||
| const emailFromLink = decodeURIComponent(searchParams.get('email') || '') | |||
| const [email, setEmail] = useState(emailFromLink) | |||
| const [loading, setIsLoading] = useState(false) | |||
| const { locale } = useContext(I18NContext) | |||
| const handleGetEMailVerificationCode = async () => { | |||
| try { | |||
| if (!email) { | |||
| Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) | |||
| return | |||
| } | |||
| if (!emailRegex.test(email)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.emailInValid'), | |||
| }) | |||
| return | |||
| } | |||
| setIsLoading(true) | |||
| const ret = await sendWebAppEMailLoginCode(email, locale) | |||
| if (ret.result === 'success') { | |||
| localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) | |||
| const params = new URLSearchParams(searchParams) | |||
| params.set('email', encodeURIComponent(email)) | |||
| params.set('token', encodeURIComponent(ret.data)) | |||
| router.push(`/webapp-signin/check-code?${params.toString()}`) | |||
| } | |||
| } | |||
| catch (error) { | |||
| console.error(error) | |||
| } | |||
| finally { | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| return (<form onSubmit={noop}> | |||
| <input type='text' className='hidden' /> | |||
| <div className='mb-2'> | |||
| <label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label> | |||
| <div className='mt-1'> | |||
| <Input id='email' type="email" value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} /> | |||
| </div> | |||
| <div className='mt-3'> | |||
| <Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.continueWithCode')}</Button> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,171 @@ | |||
| import Link from 'next/link' | |||
| import { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { useContext } from 'use-context-selector' | |||
| import Button from '@/app/components/base/button' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { emailRegex } from '@/config' | |||
| import { webAppLogin } from '@/service/common' | |||
| import Input from '@/app/components/base/input' | |||
| import I18NContext from '@/context/i18n' | |||
| import { noop } from 'lodash-es' | |||
| import { setAccessToken } from '@/app/components/share/utils' | |||
| import { fetchAccessToken } from '@/service/share' | |||
| type MailAndPasswordAuthProps = { | |||
| isEmailSetup: boolean | |||
| } | |||
| const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ | |||
| export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) { | |||
| const { t } = useTranslation() | |||
| const { locale } = useContext(I18NContext) | |||
| const router = useRouter() | |||
| const searchParams = useSearchParams() | |||
| const [showPassword, setShowPassword] = useState(false) | |||
| const emailFromLink = decodeURIComponent(searchParams.get('email') || '') | |||
| const [email, setEmail] = useState(emailFromLink) | |||
| const [password, setPassword] = useState('') | |||
| const [isLoading, setIsLoading] = useState(false) | |||
| const redirectUrl = searchParams.get('redirect_url') | |||
| const getAppCodeFromRedirectUrl = useCallback(() => { | |||
| const appCode = redirectUrl?.split('/').pop() | |||
| if (!appCode) | |||
| return null | |||
| return appCode | |||
| }, [redirectUrl]) | |||
| const handleEmailPasswordLogin = async () => { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!email) { | |||
| Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) | |||
| return | |||
| } | |||
| if (!emailRegex.test(email)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.emailInValid'), | |||
| }) | |||
| return | |||
| } | |||
| if (!password?.trim()) { | |||
| Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') }) | |||
| return | |||
| } | |||
| if (!passwordRegex.test(password)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.passwordInvalid'), | |||
| }) | |||
| return | |||
| } | |||
| if (!redirectUrl || !appCode) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.redirectUrlMissing'), | |||
| }) | |||
| return | |||
| } | |||
| try { | |||
| setIsLoading(true) | |||
| const loginData: Record<string, any> = { | |||
| email, | |||
| password, | |||
| language: locale, | |||
| remember_me: true, | |||
| } | |||
| const res = await webAppLogin({ | |||
| url: '/login', | |||
| body: loginData, | |||
| }) | |||
| if (res.result === 'success') { | |||
| localStorage.setItem('webapp_access_token', res.data.access_token) | |||
| const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: res.data.access_token }) | |||
| await setAccessToken(appCode, tokenResp.access_token) | |||
| router.replace(redirectUrl) | |||
| } | |||
| else { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: res.data, | |||
| }) | |||
| } | |||
| } | |||
| finally { | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| return <form onSubmit={noop}> | |||
| <div className='mb-3'> | |||
| <label htmlFor="email" className="system-md-semibold my-2 text-text-secondary"> | |||
| {t('login.email')} | |||
| </label> | |||
| <div className="mt-1"> | |||
| <Input | |||
| value={email} | |||
| onChange={e => setEmail(e.target.value)} | |||
| id="email" | |||
| type="email" | |||
| autoComplete="email" | |||
| placeholder={t('login.emailPlaceholder') || ''} | |||
| tabIndex={1} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div className='mb-3'> | |||
| <label htmlFor="password" className="my-2 flex items-center justify-between"> | |||
| <span className='system-md-semibold text-text-secondary'>{t('login.password')}</span> | |||
| <Link | |||
| href={`/webapp-reset-password?${searchParams.toString()}`} | |||
| className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`} | |||
| tabIndex={isEmailSetup ? 0 : -1} | |||
| aria-disabled={!isEmailSetup} | |||
| > | |||
| {t('login.forget')} | |||
| </Link> | |||
| </label> | |||
| <div className="relative mt-1"> | |||
| <Input | |||
| id="password" | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| onKeyDown={(e) => { | |||
| if (e.key === 'Enter') | |||
| handleEmailPasswordLogin() | |||
| }} | |||
| type={showPassword ? 'text' : 'password'} | |||
| autoComplete="current-password" | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| tabIndex={2} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowPassword(!showPassword)} | |||
| > | |||
| {showPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className='mb-2'> | |||
| <Button | |||
| tabIndex={2} | |||
| variant='primary' | |||
| onClick={handleEmailPasswordLogin} | |||
| disabled={isLoading || !email || !password} | |||
| className="w-full" | |||
| >{t('login.signBtn')}</Button> | |||
| </div> | |||
| </form> | |||
| } | |||
| @@ -0,0 +1,88 @@ | |||
| 'use client' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import type { FC } from 'react' | |||
| import { useCallback } from 'react' | |||
| import { useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' | |||
| import Toast from '@/app/components/base/toast' | |||
| import Button from '@/app/components/base/button' | |||
| import { SSOProtocol } from '@/types/feature' | |||
| import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' | |||
| type SSOAuthProps = { | |||
| protocol: SSOProtocol | '' | |||
| } | |||
| const SSOAuth: FC<SSOAuthProps> = ({ | |||
| protocol, | |||
| }) => { | |||
| const router = useRouter() | |||
| const { t } = useTranslation() | |||
| const searchParams = useSearchParams() | |||
| const redirectUrl = searchParams.get('redirect_url') | |||
| const getAppCodeFromRedirectUrl = useCallback(() => { | |||
| const appCode = redirectUrl?.split('/').pop() | |||
| if (!appCode) | |||
| return null | |||
| return appCode | |||
| }, [redirectUrl]) | |||
| const [isLoading, setIsLoading] = useState(false) | |||
| const handleSSOLogin = () => { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!redirectUrl || !appCode) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: 'invalid redirect URL or app code', | |||
| }) | |||
| return | |||
| } | |||
| setIsLoading(true) | |||
| if (protocol === SSOProtocol.SAML) { | |||
| fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => { | |||
| router.push(res.url) | |||
| }).finally(() => { | |||
| setIsLoading(false) | |||
| }) | |||
| } | |||
| else if (protocol === SSOProtocol.OIDC) { | |||
| fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => { | |||
| router.push(res.url) | |||
| }).finally(() => { | |||
| setIsLoading(false) | |||
| }) | |||
| } | |||
| else if (protocol === SSOProtocol.OAuth2) { | |||
| fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => { | |||
| router.push(res.url) | |||
| }).finally(() => { | |||
| setIsLoading(false) | |||
| }) | |||
| } | |||
| else { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: 'invalid SSO protocol', | |||
| }) | |||
| setIsLoading(false) | |||
| } | |||
| } | |||
| return ( | |||
| <Button | |||
| tabIndex={0} | |||
| onClick={() => { handleSSOLogin() }} | |||
| disabled={isLoading} | |||
| className="w-full" | |||
| > | |||
| <Lock01 className='mr-2 h-5 w-5 text-text-accent-light-mode-only' /> | |||
| <span className="truncate">{t('login.withSSO')}</span> | |||
| </Button> | |||
| ) | |||
| } | |||
| export default SSOAuth | |||
| @@ -0,0 +1,25 @@ | |||
| 'use client' | |||
| import cn from '@/utils/classnames' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import useDocumentTitle from '@/hooks/use-document-title' | |||
| export default function SignInLayout({ children }: any) { | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| useDocumentTitle('') | |||
| return <> | |||
| <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> | |||
| <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> | |||
| {/* <Header /> */} | |||
| <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> | |||
| <div className='flex justify-center md:w-[440px] lg:w-[600px]'> | |||
| {children} | |||
| </div> | |||
| </div> | |||
| {systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'> | |||
| © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. | |||
| </div>} | |||
| </div> | |||
| </div> | |||
| </> | |||
| } | |||
| @@ -0,0 +1,176 @@ | |||
| import React, { useCallback, useEffect, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Link from 'next/link' | |||
| import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' | |||
| import Loading from '@/app/components/base/loading' | |||
| import MailAndCodeAuth from './components/mail-and-code-auth' | |||
| import MailAndPasswordAuth from './components/mail-and-password-auth' | |||
| import SSOAuth from './components/sso-auth' | |||
| import cn from '@/utils/classnames' | |||
| import { LicenseStatus } from '@/types/feature' | |||
| import { IS_CE_EDITION } from '@/config' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| const NormalForm = () => { | |||
| const { t } = useTranslation() | |||
| const [isLoading, setIsLoading] = useState(true) | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| const [authType, updateAuthType] = useState<'code' | 'password'>('password') | |||
| const [showORLine, setShowORLine] = useState(false) | |||
| const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) | |||
| const init = useCallback(async () => { | |||
| try { | |||
| setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin) | |||
| setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login)) | |||
| updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code') | |||
| } | |||
| catch (error) { | |||
| console.error(error) | |||
| setAllMethodsAreDisabled(true) | |||
| } | |||
| finally { setIsLoading(false) } | |||
| }, [systemFeatures]) | |||
| useEffect(() => { | |||
| init() | |||
| }, [init]) | |||
| if (isLoading) { | |||
| return <div className={ | |||
| cn( | |||
| 'flex w-full grow flex-col items-center justify-center', | |||
| 'px-6', | |||
| 'md:px-[108px]', | |||
| ) | |||
| }> | |||
| <Loading type='area' /> | |||
| </div> | |||
| } | |||
| if (systemFeatures.license?.status === LicenseStatus.LOST) { | |||
| return <div className='mx-auto mt-8 w-full'> | |||
| <div className='relative'> | |||
| <div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> | |||
| <div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'> | |||
| <RiContractLine className='h-5 w-5' /> | |||
| <RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.licenseLost')}</p> | |||
| <p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseLostTip')}</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| } | |||
| if (systemFeatures.license?.status === LicenseStatus.EXPIRED) { | |||
| return <div className='mx-auto mt-8 w-full'> | |||
| <div className='relative'> | |||
| <div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> | |||
| <div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'> | |||
| <RiContractLine className='h-5 w-5' /> | |||
| <RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.licenseExpired')}</p> | |||
| <p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseExpiredTip')}</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| } | |||
| if (systemFeatures.license?.status === LicenseStatus.INACTIVE) { | |||
| return <div className='mx-auto mt-8 w-full'> | |||
| <div className='relative'> | |||
| <div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> | |||
| <div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'> | |||
| <RiContractLine className='h-5 w-5' /> | |||
| <RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.licenseInactive')}</p> | |||
| <p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseInactiveTip')}</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| } | |||
| return ( | |||
| <> | |||
| <div className="mx-auto mt-8 w-full"> | |||
| <div className="mx-auto w-full"> | |||
| <h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2> | |||
| {!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>} | |||
| </div> | |||
| <div className="relative"> | |||
| <div className="mt-6 flex flex-col gap-3"> | |||
| {systemFeatures.sso_enforced_for_signin && <div className='w-full'> | |||
| <SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} /> | |||
| </div>} | |||
| </div> | |||
| {showORLine && <div className="relative mt-6"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| <div className="relative flex justify-center"> | |||
| <span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('login.or')}</span> | |||
| </div> | |||
| </div>} | |||
| { | |||
| (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> | |||
| {systemFeatures.enable_email_code_login && authType === 'code' && <> | |||
| <MailAndCodeAuth /> | |||
| {systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}> | |||
| <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span> | |||
| </div>} | |||
| </>} | |||
| {systemFeatures.enable_email_password_login && authType === 'password' && <> | |||
| <MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} /> | |||
| {systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}> | |||
| <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span> | |||
| </div>} | |||
| </>} | |||
| </> | |||
| } | |||
| {allMethodsAreDisabled && <> | |||
| <div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> | |||
| <div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'> | |||
| <RiDoorLockLine className='h-5 w-5' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p> | |||
| <p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.noLoginMethodTip')}</p> | |||
| </div> | |||
| <div className="relative my-2 py-2"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| </div> | |||
| </>} | |||
| {!systemFeatures.branding.enabled && <> | |||
| <div className="system-xs-regular mt-2 block w-full text-text-tertiary"> | |||
| {t('login.tosDesc')} | |||
| | |||
| <Link | |||
| className='system-xs-medium text-text-secondary hover:underline' | |||
| target='_blank' rel='noopener noreferrer' | |||
| href='https://dify.ai/terms' | |||
| >{t('login.tos')}</Link> | |||
| & | |||
| <Link | |||
| className='system-xs-medium text-text-secondary hover:underline' | |||
| target='_blank' rel='noopener noreferrer' | |||
| href='https://dify.ai/privacy' | |||
| >{t('login.pp')}</Link> | |||
| </div> | |||
| {IS_CE_EDITION && <div className="w-hull system-xs-regular mt-2 block text-text-tertiary"> | |||
| {t('login.goToInit')} | |||
| | |||
| <Link | |||
| className='system-xs-medium text-text-secondary hover:underline' | |||
| href='/install' | |||
| >{t('login.setAdminAccount')}</Link> | |||
| </div>} | |||
| </>} | |||
| </div> | |||
| </div> | |||
| </> | |||
| ) | |||
| } | |||
| export default NormalForm | |||
| @@ -3,19 +3,20 @@ import { useRouter, useSearchParams } from 'next/navigation' | |||
| import type { FC } from 'react' | |||
| import React, { useCallback, useEffect } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiDoorLockLine } from '@remixicon/react' | |||
| import cn from '@/utils/classnames' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' | |||
| import { setAccessToken } from '@/app/components/share/utils' | |||
| import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { SSOProtocol } from '@/types/feature' | |||
| import Loading from '@/app/components/base/loading' | |||
| import AppUnavailable from '@/app/components/base/app-unavailable' | |||
| import NormalForm from './normalForm' | |||
| import { AccessMode } from '@/models/access-control' | |||
| import ExternalMemberSsoAuth from './components/external-member-sso-auth' | |||
| import { fetchAccessToken } from '@/service/share' | |||
| const WebSSOForm: FC = () => { | |||
| const { t } = useTranslation() | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) | |||
| const searchParams = useSearchParams() | |||
| const router = useRouter() | |||
| @@ -23,10 +24,22 @@ const WebSSOForm: FC = () => { | |||
| const tokenFromUrl = searchParams.get('web_sso_token') | |||
| const message = searchParams.get('message') | |||
| const showErrorToast = (message: string) => { | |||
| const getSigninUrl = useCallback(() => { | |||
| const params = new URLSearchParams(searchParams) | |||
| params.delete('message') | |||
| return `/webapp-signin?${params.toString()}` | |||
| }, [searchParams]) | |||
| const backToHome = useCallback(() => { | |||
| removeAccessToken() | |||
| const url = getSigninUrl() | |||
| router.replace(url) | |||
| }, [getSigninUrl, router]) | |||
| const showErrorToast = (msg: string) => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message, | |||
| message: msg, | |||
| }) | |||
| } | |||
| @@ -38,102 +51,73 @@ const WebSSOForm: FC = () => { | |||
| return appCode | |||
| }, [redirectUrl]) | |||
| const processTokenAndRedirect = useCallback(async () => { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!appCode || !tokenFromUrl || !redirectUrl) { | |||
| showErrorToast('redirect url or app code or token is invalid.') | |||
| return | |||
| } | |||
| await setAccessToken(appCode, tokenFromUrl) | |||
| router.push(redirectUrl) | |||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl]) | |||
| const handleSSOLogin = useCallback(async () => { | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (!appCode || !redirectUrl) { | |||
| showErrorToast('redirect url or app code is invalid.') | |||
| return | |||
| } | |||
| switch (systemFeatures.webapp_auth.sso_config.protocol) { | |||
| case SSOProtocol.SAML: { | |||
| const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) | |||
| router.push(samlRes.url) | |||
| break | |||
| } | |||
| case SSOProtocol.OIDC: { | |||
| const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) | |||
| router.push(oidcRes.url) | |||
| break | |||
| } | |||
| case SSOProtocol.OAuth2: { | |||
| const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) | |||
| router.push(oauth2Res.url) | |||
| break | |||
| } | |||
| case '': | |||
| break | |||
| default: | |||
| showErrorToast('SSO protocol is not supported.') | |||
| } | |||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) | |||
| useEffect(() => { | |||
| const init = async () => { | |||
| if (message) { | |||
| showErrorToast(message) | |||
| (async () => { | |||
| if (message) | |||
| return | |||
| } | |||
| if (!tokenFromUrl) { | |||
| await handleSSOLogin() | |||
| const appCode = getAppCodeFromRedirectUrl() | |||
| if (appCode && tokenFromUrl && redirectUrl) { | |||
| localStorage.setItem('webapp_access_token', tokenFromUrl) | |||
| const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl }) | |||
| await setAccessToken(appCode, tokenResp.access_token) | |||
| router.replace(redirectUrl) | |||
| return | |||
| } | |||
| if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) { | |||
| const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) | |||
| await setAccessToken(appCode, tokenResp.access_token) | |||
| router.replace(redirectUrl) | |||
| } | |||
| })() | |||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) | |||
| await processTokenAndRedirect() | |||
| } | |||
| useEffect(() => { | |||
| if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) | |||
| router.replace(redirectUrl) | |||
| }, [webAppAccessMode, router, redirectUrl]) | |||
| init() | |||
| }, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin]) | |||
| if (tokenFromUrl) | |||
| return <div className='flex h-full items-center justify-center'><Loading /></div> | |||
| if (message) { | |||
| if (tokenFromUrl) { | |||
| return <div className='flex h-full items-center justify-center'> | |||
| <AppUnavailable code={'App Unavailable'} unknownReason={message} /> | |||
| <Loading /> | |||
| </div> | |||
| } | |||
| if (systemFeatures.webapp_auth.enabled) { | |||
| if (systemFeatures.webapp_auth.allow_sso) { | |||
| return ( | |||
| <div className="flex h-full items-center justify-center"> | |||
| <div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}> | |||
| <Loading /> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| return <div className="flex h-full items-center justify-center"> | |||
| <div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4"> | |||
| <div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'> | |||
| <RiDoorLockLine className='h-5 w-5' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.webapp.noLoginMethod')}</p> | |||
| <p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.webapp.noLoginMethodTip')}</p> | |||
| </div> | |||
| <div className="relative my-2 py-2"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div> | |||
| </div> | |||
| </div> | |||
| if (message) { | |||
| return <div className='flex h-full flex-col items-center justify-center gap-y-4'> | |||
| <AppUnavailable className='h-auto w-auto' code={t('share.common.appUnavailable')} unknownReason={message} /> | |||
| <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span> | |||
| </div> | |||
| } | |||
| if (!redirectUrl) { | |||
| showErrorToast('redirect url is invalid.') | |||
| return <div className='flex h-full items-center justify-center'> | |||
| <AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' /> | |||
| </div> | |||
| } | |||
| if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { | |||
| return <div className='flex h-full items-center justify-center'> | |||
| <Loading /> | |||
| </div> | |||
| } | |||
| else { | |||
| if (!systemFeatures.webapp_auth.enabled) { | |||
| return <div className="flex h-full items-center justify-center"> | |||
| <p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p> | |||
| </div> | |||
| } | |||
| if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) { | |||
| return <div className='w-full max-w-[400px]'> | |||
| <NormalForm /> | |||
| </div> | |||
| } | |||
| if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS) | |||
| return <ExternalMemberSsoAuth /> | |||
| return <div className='flex h-full flex-col items-center justify-center gap-y-4'> | |||
| <AppUnavailable className='h-auto w-auto' isUnknownReason={true} /> | |||
| <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span> | |||
| </div> | |||
| } | |||
| export default React.memo(WebSSOForm) | |||
| @@ -1,6 +1,6 @@ | |||
| 'use client' | |||
| import { Dialog } from '@headlessui/react' | |||
| import { RiBuildingLine, RiGlobalLine } from '@remixicon/react' | |||
| import { Description as DialogDescription, DialogTitle } from '@headlessui/react' | |||
| import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useCallback, useEffect } from 'react' | |||
| import Button from '../../base/button' | |||
| @@ -67,8 +67,8 @@ export default function AccessControl(props: AccessControlProps) { | |||
| return <AccessControlDialog show onClose={onClose}> | |||
| <div className='flex flex-col gap-y-3'> | |||
| <div className='pb-3 pl-6 pr-14 pt-6'> | |||
| <Dialog.Title className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</Dialog.Title> | |||
| <Dialog.Description className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</Dialog.Description> | |||
| <DialogTitle className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</DialogTitle> | |||
| <DialogDescription className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</DialogDescription> | |||
| </div> | |||
| <div className='flex flex-col gap-y-1 px-6 pb-3'> | |||
| <div className='leading-6'> | |||
| @@ -80,12 +80,20 @@ export default function AccessControl(props: AccessControlProps) { | |||
| <RiBuildingLine className='h-4 w-4 text-text-primary' /> | |||
| <p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p> | |||
| </div> | |||
| {!hideTip && <WebAppSSONotEnabledTip />} | |||
| </div> | |||
| </AccessControlItem> | |||
| <AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}> | |||
| <SpecificGroupsOrMembers /> | |||
| </AccessControlItem> | |||
| <AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}> | |||
| <div className='flex items-center p-3'> | |||
| <div className='flex grow items-center gap-x-2'> | |||
| <RiVerifiedBadgeLine className='h-4 w-4 text-text-primary' /> | |||
| <p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.external')}</p> | |||
| </div> | |||
| {!hideTip && <WebAppSSONotEnabledTip />} | |||
| </div> | |||
| </AccessControlItem> | |||
| <AccessControlItem type={AccessMode.PUBLIC}> | |||
| <div className='flex items-center gap-x-2 p-3'> | |||
| <RiGlobalLine className='h-4 w-4 text-text-primary' /> | |||
| @@ -3,12 +3,10 @@ import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useCallback, useEffect } from 'react' | |||
| import Avatar from '../../base/avatar' | |||
| import Divider from '../../base/divider' | |||
| import Tooltip from '../../base/tooltip' | |||
| import Loading from '../../base/loading' | |||
| import useAccessControlStore from '../../../../context/access-control-store' | |||
| import AddMemberOrGroupDialog from './add-member-or-group-pop' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' | |||
| import { AccessMode } from '@/models/access-control' | |||
| import { useAppWhiteListSubjects } from '@/service/access-control' | |||
| @@ -19,11 +17,6 @@ export default function SpecificGroupsOrMembers() { | |||
| const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) | |||
| const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) | |||
| const { t } = useTranslation() | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const hideTip = systemFeatures.webapp_auth.enabled | |||
| && (systemFeatures.webapp_auth.allow_sso | |||
| || systemFeatures.webapp_auth.allow_email_password_login | |||
| || systemFeatures.webapp_auth.allow_email_code_login) | |||
| const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) | |||
| useEffect(() => { | |||
| @@ -37,7 +30,6 @@ export default function SpecificGroupsOrMembers() { | |||
| <RiLockLine className='h-4 w-4 text-text-primary' /> | |||
| <p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p> | |||
| </div> | |||
| {!hideTip && <WebAppSSONotEnabledTip />} | |||
| </div> | |||
| } | |||
| @@ -48,10 +40,6 @@ export default function SpecificGroupsOrMembers() { | |||
| <p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p> | |||
| </div> | |||
| <div className='flex items-center gap-x-1'> | |||
| {!hideTip && <> | |||
| <WebAppSSONotEnabledTip /> | |||
| <Divider className='ml-2 mr-0 h-[14px]' type="vertical" /> | |||
| </>} | |||
| <AddMemberOrGroupDialog /> | |||
| </div> | |||
| </div> | |||
| @@ -9,11 +9,14 @@ import dayjs from 'dayjs' | |||
| import { | |||
| RiArrowDownSLine, | |||
| RiArrowRightSLine, | |||
| RiBuildingLine, | |||
| RiGlobalLine, | |||
| RiLockLine, | |||
| RiPlanetLine, | |||
| RiPlayCircleLine, | |||
| RiPlayList2Line, | |||
| RiTerminalBoxLine, | |||
| RiVerifiedBadgeLine, | |||
| } from '@remixicon/react' | |||
| import { useKeyPress } from 'ahooks' | |||
| import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' | |||
| @@ -276,10 +279,30 @@ const AppPublisher = ({ | |||
| setShowAppAccessControl(true) | |||
| }}> | |||
| <div className='flex grow items-center gap-x-1.5 pr-1'> | |||
| <RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| {appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>} | |||
| {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>} | |||
| {appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>} | |||
| {appDetail?.access_mode === AccessMode.ORGANIZATION | |||
| && <> | |||
| <RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS | |||
| && <> | |||
| <RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.PUBLIC | |||
| && <> | |||
| <RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS | |||
| && <> | |||
| <RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p> | |||
| </> | |||
| } | |||
| </div> | |||
| {!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>} | |||
| <div className='flex h-4 w-4 shrink-0 items-center justify-center'> | |||
| @@ -5,10 +5,13 @@ import { useTranslation } from 'react-i18next' | |||
| import { | |||
| RiArrowRightSLine, | |||
| RiBookOpenLine, | |||
| RiBuildingLine, | |||
| RiEqualizer2Line, | |||
| RiExternalLinkLine, | |||
| RiGlobalLine, | |||
| RiLockLine, | |||
| RiPaintBrushLine, | |||
| RiVerifiedBadgeLine, | |||
| RiWindowLine, | |||
| } from '@remixicon/react' | |||
| import SettingsModal from './settings' | |||
| @@ -248,11 +251,30 @@ function AppCard({ | |||
| <div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2' | |||
| onClick={handleClickAccessControl}> | |||
| <div className='flex grow items-center gap-x-1.5 pr-1'> | |||
| <RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| {appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>} | |||
| {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>} | |||
| {appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>} | |||
| </div> | |||
| {appDetail?.access_mode === AccessMode.ORGANIZATION | |||
| && <> | |||
| <RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS | |||
| && <> | |||
| <RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.PUBLIC | |||
| && <> | |||
| <RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p> | |||
| </> | |||
| } | |||
| {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS | |||
| && <> | |||
| <RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||
| <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p> | |||
| </> | |||
| }</div> | |||
| {!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>} | |||
| <div className='flex h-4 w-4 shrink-0 items-center justify-center'> | |||
| <RiArrowRightSLine className='h-4 w-4 text-text-quaternary' /> | |||
| @@ -1,4 +1,5 @@ | |||
| 'use client' | |||
| import classNames from '@/utils/classnames' | |||
| import type { FC } from 'react' | |||
| import React from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| @@ -7,17 +8,19 @@ type IAppUnavailableProps = { | |||
| code?: number | string | |||
| isUnknownReason?: boolean | |||
| unknownReason?: string | |||
| className?: string | |||
| } | |||
| const AppUnavailable: FC<IAppUnavailableProps> = ({ | |||
| code = 404, | |||
| isUnknownReason, | |||
| unknownReason, | |||
| className, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <div className='flex h-screen w-screen items-center justify-center'> | |||
| <div className={classNames('flex h-screen w-screen items-center justify-center', className)}> | |||
| <h1 className='mr-5 h-[50px] pr-5 text-[24px] font-medium leading-[50px]' | |||
| style={{ | |||
| borderRight: '1px solid rgba(0,0,0,.3)', | |||
| @@ -16,14 +16,12 @@ import type { | |||
| ConversationItem, | |||
| } from '@/models/share' | |||
| import { noop } from 'lodash-es' | |||
| import { AccessMode } from '@/models/access-control' | |||
| export type ChatWithHistoryContextValue = { | |||
| appInfoError?: any | |||
| appInfoLoading?: boolean | |||
| appMeta?: AppMeta | |||
| appData?: AppData | |||
| accessMode?: AccessMode | |||
| userCanAccess?: boolean | |||
| appParams?: ChatConfig | |||
| appChatListDataLoading?: boolean | |||
| @@ -64,7 +62,6 @@ export type ChatWithHistoryContextValue = { | |||
| } | |||
| export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({ | |||
| accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, | |||
| userCanAccess: false, | |||
| currentConversationId: '', | |||
| appPrevChatTree: [], | |||
| @@ -43,9 +43,8 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' | |||
| import { InputVarType } from '@/app/components/workflow/types' | |||
| import { TransferMethod } from '@/types/app' | |||
| import { noop } from 'lodash-es' | |||
| import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' | |||
| import { useGetUserCanAccessApp } from '@/service/access-control' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { AccessMode } from '@/models/access-control' | |||
| function getFormattedChatList(messages: any[]) { | |||
| const newChatList: ChatItem[] = [] | |||
| @@ -77,11 +76,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { | |||
| const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) | |||
| const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ | |||
| appId: installedAppInfo?.app.id || appInfo?.app_id, | |||
| isInstalledApp, | |||
| enabled: systemFeatures.webapp_auth.enabled, | |||
| }) | |||
| const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ | |||
| appId: installedAppInfo?.app.id || appInfo?.app_id, | |||
| isInstalledApp, | |||
| @@ -492,8 +486,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { | |||
| return { | |||
| appInfoError, | |||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), | |||
| accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, | |||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), | |||
| userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, | |||
| isInstalledApp, | |||
| appId, | |||
| @@ -124,7 +124,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({ | |||
| const { | |||
| appInfoError, | |||
| appInfoLoading, | |||
| accessMode, | |||
| userCanAccess, | |||
| appData, | |||
| appParams, | |||
| @@ -169,7 +168,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({ | |||
| appInfoError, | |||
| appInfoLoading, | |||
| appData, | |||
| accessMode, | |||
| userCanAccess, | |||
| appParams, | |||
| appMeta, | |||
| @@ -19,7 +19,6 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re | |||
| import DifyLogo from '@/app/components/base/logo/dify-logo' | |||
| import type { ConversationItem } from '@/models/share' | |||
| import cn from '@/utils/classnames' | |||
| import { AccessMode } from '@/models/access-control' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| type Props = { | |||
| @@ -30,7 +29,6 @@ const Sidebar = ({ isPanel }: Props) => { | |||
| const { t } = useTranslation() | |||
| const { | |||
| isInstalledApp, | |||
| accessMode, | |||
| appData, | |||
| handleNewConversation, | |||
| pinnedConversationList, | |||
| @@ -140,7 +138,7 @@ const Sidebar = ({ isPanel }: Props) => { | |||
| )} | |||
| </div> | |||
| <div className='flex shrink-0 items-center justify-between p-3'> | |||
| <MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} placement='top-start' data={appData?.site} /> | |||
| <MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} /> | |||
| {/* powered by */} | |||
| <div className='shrink-0'> | |||
| {!appData?.custom_config?.remove_webapp_brand && ( | |||
| @@ -15,10 +15,8 @@ import type { | |||
| ConversationItem, | |||
| } from '@/models/share' | |||
| import { noop } from 'lodash-es' | |||
| import { AccessMode } from '@/models/access-control' | |||
| export type EmbeddedChatbotContextValue = { | |||
| accessMode?: AccessMode | |||
| userCanAccess?: boolean | |||
| appInfoError?: any | |||
| appInfoLoading?: boolean | |||
| @@ -58,7 +56,6 @@ export type EmbeddedChatbotContextValue = { | |||
| export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ | |||
| userCanAccess: false, | |||
| accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, | |||
| currentConversationId: '', | |||
| appPrevChatList: [], | |||
| pinnedConversationList: [], | |||
| @@ -36,9 +36,8 @@ import { InputVarType } from '@/app/components/workflow/types' | |||
| import { TransferMethod } from '@/types/app' | |||
| import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' | |||
| import { noop } from 'lodash-es' | |||
| import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' | |||
| import { useGetUserCanAccessApp } from '@/service/access-control' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { AccessMode } from '@/models/access-control' | |||
| function getFormattedChatList(messages: any[]) { | |||
| const newChatList: ChatItem[] = [] | |||
| @@ -70,11 +69,6 @@ export const useEmbeddedChatbot = () => { | |||
| const isInstalledApp = false | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) | |||
| const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ | |||
| appId: appInfo?.app_id, | |||
| isInstalledApp, | |||
| enabled: systemFeatures.webapp_auth.enabled, | |||
| }) | |||
| const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ | |||
| appId: appInfo?.app_id, | |||
| isInstalledApp, | |||
| @@ -385,8 +379,7 @@ export const useEmbeddedChatbot = () => { | |||
| return { | |||
| appInfoError, | |||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), | |||
| accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, | |||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), | |||
| userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, | |||
| isInstalledApp, | |||
| allowResetChat, | |||
| @@ -6,9 +6,8 @@ import type { Placement } from '@floating-ui/react' | |||
| import { | |||
| RiEqualizer2Line, | |||
| } from '@remixicon/react' | |||
| import { useRouter } from 'next/navigation' | |||
| import { usePathname, useRouter } from 'next/navigation' | |||
| import Divider from '../../base/divider' | |||
| import { removeAccessToken } from '../utils' | |||
| import InfoModal from './info-modal' | |||
| import ActionButton from '@/app/components/base/action-button' | |||
| import { | |||
| @@ -19,6 +18,8 @@ import { | |||
| import ThemeSwitcher from '@/app/components/base/theme-switcher' | |||
| import type { SiteInfo } from '@/models/share' | |||
| import cn from '@/utils/classnames' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { AccessMode } from '@/models/access-control' | |||
| type Props = { | |||
| data?: SiteInfo | |||
| @@ -31,7 +32,9 @@ const MenuDropdown: FC<Props> = ({ | |||
| placement, | |||
| hideLogout, | |||
| }) => { | |||
| const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) | |||
| const router = useRouter() | |||
| const pathname = usePathname() | |||
| const { t } = useTranslation() | |||
| const [open, doSetOpen] = useState(false) | |||
| const openRef = useRef(open) | |||
| @@ -45,9 +48,10 @@ const MenuDropdown: FC<Props> = ({ | |||
| }, [setOpen]) | |||
| const handleLogout = useCallback(() => { | |||
| removeAccessToken() | |||
| router.replace(`/webapp-signin?redirect_url=${window.location.href}`) | |||
| }, [router]) | |||
| localStorage.removeItem('token') | |||
| localStorage.removeItem('webapp_access_token') | |||
| router.replace(`/webapp-signin?redirect_url=${pathname}`) | |||
| }, [router, pathname]) | |||
| const [show, setShow] = useState(false) | |||
| @@ -92,6 +96,16 @@ const MenuDropdown: FC<Props> = ({ | |||
| className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' | |||
| >{t('common.userProfile.about')}</div> | |||
| </div> | |||
| {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( | |||
| <div className='p-1'> | |||
| <div | |||
| onClick={handleLogout} | |||
| className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' | |||
| > | |||
| {t('common.userProfile.logout')} | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| </PortalToFollowElemContent> | |||
| </PortalToFollowElem> | |||
| @@ -10,8 +10,8 @@ export const getInitialTokenV2 = (): Record<string, any> => ({ | |||
| version: 2, | |||
| }) | |||
| export const checkOrSetAccessToken = async () => { | |||
| const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0] | |||
| export const checkOrSetAccessToken = async (appCode?: string) => { | |||
| const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] | |||
| const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id | |||
| const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) | |||
| let accessTokenJson = getInitialTokenV2() | |||
| @@ -23,8 +23,10 @@ export const checkOrSetAccessToken = async () => { | |||
| catch { | |||
| } | |||
| if (!accessTokenJson[sharedToken]?.[userId || 'DEFAULT']) { | |||
| const res = await fetchAccessToken(sharedToken, userId) | |||
| const webAppAccessToken = localStorage.getItem('webapp_access_token') | |||
| const res = await fetchAccessToken({ appCode: sharedToken, userId, webAppAccessToken }) | |||
| accessTokenJson[sharedToken] = { | |||
| ...accessTokenJson[sharedToken], | |||
| [userId || 'DEFAULT']: res.access_token, | |||
| @@ -33,7 +35,7 @@ export const checkOrSetAccessToken = async () => { | |||
| } | |||
| } | |||
| export const setAccessToken = async (sharedToken: string, token: string, user_id?: string) => { | |||
| export const setAccessToken = (sharedToken: string, token: string, user_id?: string) => { | |||
| const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) | |||
| let accessTokenJson = getInitialTokenV2() | |||
| try { | |||
| @@ -69,6 +71,7 @@ export const removeAccessToken = () => { | |||
| } | |||
| localStorage.removeItem(CONVERSATION_ID_INFO) | |||
| localStorage.removeItem('webapp_access_token') | |||
| delete accessTokenJson[sharedToken] | |||
| localStorage.setItem('token', JSON.stringify(accessTokenJson)) | |||
| @@ -1,8 +1,8 @@ | |||
| 'use client' | |||
| import type { FC } from 'react' | |||
| import classNames from '@/utils/classnames' | |||
| import { useSelector } from '@/context/app-context' | |||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { useTheme } from 'next-themes' | |||
| type LoginLogoProps = { | |||
| className?: string | |||
| @@ -12,11 +12,7 @@ const LoginLogo: FC<LoginLogoProps> = ({ | |||
| className, | |||
| }) => { | |||
| const { systemFeatures } = useGlobalPublicStore() | |||
| const { theme } = useSelector((s) => { | |||
| return { | |||
| theme: s.theme, | |||
| } | |||
| }) | |||
| const { theme } = useTheme() | |||
| let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png` | |||
| if (systemFeatures.branding.enabled) | |||
| @@ -7,19 +7,24 @@ import type { SystemFeatures } from '@/types/feature' | |||
| import { defaultSystemFeatures } from '@/types/feature' | |||
| import { getSystemFeatures } from '@/service/common' | |||
| import Loading from '@/app/components/base/loading' | |||
| import { AccessMode } from '@/models/access-control' | |||
| type GlobalPublicStore = { | |||
| isPending: boolean | |||
| setIsPending: (isPending: boolean) => void | |||
| isGlobalPending: boolean | |||
| setIsGlobalPending: (isPending: boolean) => void | |||
| systemFeatures: SystemFeatures | |||
| setSystemFeatures: (systemFeatures: SystemFeatures) => void | |||
| webAppAccessMode: AccessMode, | |||
| setWebAppAccessMode: (webAppAccessMode: AccessMode) => void | |||
| } | |||
| export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({ | |||
| isPending: true, | |||
| setIsPending: (isPending: boolean) => set(() => ({ isPending })), | |||
| isGlobalPending: true, | |||
| setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), | |||
| systemFeatures: defaultSystemFeatures, | |||
| setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), | |||
| webAppAccessMode: AccessMode.PUBLIC, | |||
| setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), | |||
| })) | |||
| const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({ | |||
| @@ -29,7 +34,7 @@ const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({ | |||
| queryKey: ['systemFeatures'], | |||
| queryFn: getSystemFeatures, | |||
| }) | |||
| const { setSystemFeatures, setIsPending } = useGlobalPublicStore() | |||
| const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore() | |||
| useEffect(() => { | |||
| if (data) | |||
| setSystemFeatures({ ...defaultSystemFeatures, ...data }) | |||
| @@ -11,7 +11,7 @@ describe('title should be empty if systemFeatures is pending', () => { | |||
| act(() => { | |||
| useGlobalPublicStore.setState({ | |||
| systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, | |||
| isPending: true, | |||
| isGlobalPending: true, | |||
| }) | |||
| }) | |||
| it('document title should be empty if set title', () => { | |||
| @@ -28,7 +28,7 @@ describe('use default branding', () => { | |||
| beforeEach(() => { | |||
| act(() => { | |||
| useGlobalPublicStore.setState({ | |||
| isPending: false, | |||
| isGlobalPending: false, | |||
| systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } }, | |||
| }) | |||
| }) | |||
| @@ -48,7 +48,7 @@ describe('use specific branding', () => { | |||
| beforeEach(() => { | |||
| act(() => { | |||
| useGlobalPublicStore.setState({ | |||
| isPending: false, | |||
| isGlobalPending: false, | |||
| systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } }, | |||
| }) | |||
| }) | |||
| @@ -3,7 +3,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' | |||
| import { useFavicon, useTitle } from 'ahooks' | |||
| export default function useDocumentTitle(title: string) { | |||
| const isPending = useGlobalPublicStore(s => s.isPending) | |||
| const isPending = useGlobalPublicStore(s => s.isGlobalPending) | |||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||
| const prefix = title ? `${title} - ` : '' | |||
| let titleStr = '' | |||
| @@ -197,9 +197,10 @@ const translation = { | |||
| }, | |||
| accessControl: 'Web App Access Control', | |||
| accessItemsDescription: { | |||
| anyone: 'Anyone can access the web app', | |||
| specific: 'Only specific groups or members can access the web app', | |||
| organization: 'Anyone in the organization can access the web app', | |||
| anyone: 'Anyone can access the web app (no login required)', | |||
| specific: 'Only specific members within the platform can access the Web application', | |||
| organization: 'All members within the platform can access the Web application', | |||
| external: 'Only authenticated external users can access the Web application', | |||
| }, | |||
| accessControlDialog: { | |||
| title: 'Web App Access Control', | |||
| @@ -207,15 +208,16 @@ const translation = { | |||
| accessLabel: 'Who has access', | |||
| accessItems: { | |||
| anyone: 'Anyone with the link', | |||
| specific: 'Specific groups or members', | |||
| organization: 'Only members within the enterprise', | |||
| specific: 'Specific members within the platform', | |||
| organization: 'All members within the platform', | |||
| external: 'Authenticated external users', | |||
| }, | |||
| groups_one: '{{count}} GROUP', | |||
| groups_other: '{{count}} GROUPS', | |||
| members_one: '{{count}} MEMBER', | |||
| members_other: '{{count}} MEMBERS', | |||
| noGroupsOrMembers: 'No groups or members selected', | |||
| webAppSSONotEnabledTip: 'Please contact enterprise administrator to configure the web app authentication method.', | |||
| webAppSSONotEnabledTip: 'Please contact your organization administrator to configure external authentication for the Web application.', | |||
| operateGroupAndMember: { | |||
| searchPlaceholder: 'Search groups and members', | |||
| allMembers: 'All members', | |||
| @@ -77,6 +77,9 @@ const translation = { | |||
| atLeastOne: 'Please input at least one row in the uploaded file.', | |||
| }, | |||
| }, | |||
| login: { | |||
| backToHome: 'Back to Home', | |||
| }, | |||
| } | |||
| export default translation | |||
| @@ -210,30 +210,27 @@ const translation = { | |||
| }, | |||
| accessControl: 'Web アプリアクセス制御', | |||
| accessItemsDescription: { | |||
| anyone: '誰でも Web アプリにアクセス可能', | |||
| specific: '特定のグループまたはメンバーのみが Web アプリにアクセス可能', | |||
| organization: '組織内の誰でも Web アプリにアクセス可能', | |||
| anyone: '誰でもこの web アプリにアクセスできます(ログイン不要)', | |||
| specific: '特定のプラットフォーム内メンバーのみがこの Web アプリにアクセスできます', | |||
| organization: 'プラットフォーム内の全メンバーがこの Web アプリにアクセスできます', | |||
| external: '認証済みの外部ユーザーのみがこの Web アプリにアクセスできます', | |||
| }, | |||
| accessControlDialog: { | |||
| title: 'アクセス権限', | |||
| description: 'Web アプリのアクセス権限を設定します', | |||
| accessLabel: '誰がアクセスできますか', | |||
| accessItemsDescription: { | |||
| anyone: '誰でも Web アプリにアクセス可能です', | |||
| specific: '特定のグループやメンバーが Web アプリにアクセス可能です', | |||
| organization: '組織内の誰でも Web アプリにアクセス可能です', | |||
| }, | |||
| accessItems: { | |||
| anyone: 'すべてのユーザー', | |||
| specific: '特定のグループメンバー', | |||
| organization: 'グループ内の全員', | |||
| anyone: 'リンクを知っているすべてのユーザー', | |||
| specific: '特定のプラットフォーム内メンバー', | |||
| organization: 'プラットフォーム内の全メンバー', | |||
| external: '認証済みの外部ユーザー', | |||
| }, | |||
| groups_one: '{{count}} グループ', | |||
| groups_other: '{{count}} グループ', | |||
| members_one: '{{count}} メンバー', | |||
| members_other: '{{count}} メンバー', | |||
| noGroupsOrMembers: 'グループまたはメンバーが選択されていません', | |||
| webAppSSONotEnabledTip: 'Web アプリの認証方式設定については、企業管理者へご連絡ください。', | |||
| webAppSSONotEnabledTip: 'Web アプリの外部認証方式を設定するには、組織の管理者にお問い合わせください。', | |||
| operateGroupAndMember: { | |||
| searchPlaceholder: 'グループやメンバーを検索', | |||
| allMembers: 'すべてのメンバー', | |||
| @@ -73,6 +73,9 @@ const translation = { | |||
| atLeastOne: '1 行以上のデータが必要です', | |||
| }, | |||
| }, | |||
| login: { | |||
| backToHome: 'ホームに戻る', | |||
| }, | |||
| } | |||
| export default translation | |||
| @@ -198,30 +198,27 @@ const translation = { | |||
| }, | |||
| accessControl: 'Web 应用访问控制', | |||
| accessItemsDescription: { | |||
| anyone: '任何人可以访问 web 应用', | |||
| specific: '特定组或成员可以访问 web 应用', | |||
| organization: '组织内任何人可以访问 web 应用', | |||
| anyone: '任何人都可以访问该 web 应用(无需登录)', | |||
| specific: '仅指定的平台内成员可访问该 Web 应用', | |||
| organization: '平台内所有成员均可访问该 Web 应用', | |||
| external: '仅经认证的外部用户可访问该 Web 应用', | |||
| }, | |||
| accessControlDialog: { | |||
| title: 'Web 应用访问权限', | |||
| description: '设置 web 应用访问权限。', | |||
| accessLabel: '谁可以访问', | |||
| accessItemsDescription: { | |||
| anyone: '任何人可以访问 web 应用', | |||
| specific: '特定组或成员可以访问 web 应用', | |||
| organization: '组织内任何人可以访问 web 应用', | |||
| }, | |||
| accessItems: { | |||
| anyone: '任何人', | |||
| specific: '特定组或成员', | |||
| organization: '组织内任何人', | |||
| specific: '平台内指定成员', | |||
| organization: '平台内所有成员', | |||
| external: '经认证的外部用户', | |||
| }, | |||
| groups_one: '{{count}} 个组', | |||
| groups_other: '{{count}} 个组', | |||
| members_one: '{{count}} 个成员', | |||
| members_other: '{{count}} 个成员', | |||
| noGroupsOrMembers: '未选择分组或成员', | |||
| webAppSSONotEnabledTip: '请联系企业管理员配置 web 应用的身份认证方式。', | |||
| webAppSSONotEnabledTip: '请联系企业管理员配置 Web 应用外部认证方式。', | |||
| operateGroupAndMember: { | |||
| searchPlaceholder: '搜索组或成员', | |||
| allMembers: '所有成员', | |||
| @@ -73,6 +73,9 @@ const translation = { | |||
| atLeastOne: '上传文件的内容不能少于一条', | |||
| }, | |||
| }, | |||
| login: { | |||
| backToHome: '返回首页', | |||
| }, | |||
| } | |||
| export default translation | |||
| @@ -7,6 +7,7 @@ export enum AccessMode { | |||
| PUBLIC = 'public', | |||
| SPECIFIC_GROUPS_MEMBERS = 'private', | |||
| ORGANIZATION = 'private_all', | |||
| EXTERNAL_MEMBERS = 'sso_verified', | |||
| } | |||
| export type AccessControlGroup = { | |||
| @@ -109,6 +109,7 @@ function unicodeToChar(text: string) { | |||
| } | |||
| function requiredWebSSOLogin(message?: string) { | |||
| removeAccessToken() | |||
| const params = new URLSearchParams() | |||
| params.append('redirect_url', globalThis.location.pathname) | |||
| if (message) | |||
| @@ -52,6 +52,9 @@ type LoginResponse = LoginSuccess | LoginFail | |||
| export const login: Fetcher<LoginResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => { | |||
| return post(url, { body }) as Promise<LoginResponse> | |||
| } | |||
| export const webAppLogin: Fetcher<LoginResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => { | |||
| return post(url, { body }, { isPublicAPI: true }) as Promise<LoginResponse> | |||
| } | |||
| export const fetchNewToken: Fetcher<CommonResponse & { data: { access_token: string; refresh_token: string } }, { body: Record<string, any> }> = ({ body }) => { | |||
| return post('/refresh-token', { body }) as Promise<CommonResponse & { data: { access_token: string; refresh_token: string } }> | |||
| @@ -324,6 +327,16 @@ export const verifyForgotPasswordToken: Fetcher<CommonResponse & { is_valid: boo | |||
| export const changePasswordWithToken: Fetcher<CommonResponse, { url: string; body: { token: string; new_password: string; password_confirm: string } }> = ({ url, body }) => | |||
| post<CommonResponse>(url, { body }) | |||
| export const sendWebAppForgotPasswordEmail: Fetcher<CommonResponse & { data: string }, { url: string; body: { email: string } }> = ({ url, body }) => | |||
| post<CommonResponse & { data: string }>(url, { body }, { isPublicAPI: true }) | |||
| export const verifyWebAppForgotPasswordToken: Fetcher<CommonResponse & { is_valid: boolean; email: string }, { url: string; body: { token: string } }> = ({ url, body }) => { | |||
| return post(url, { body }, { isPublicAPI: true }) as Promise<CommonResponse & { is_valid: boolean; email: string }> | |||
| } | |||
| export const changeWebAppPasswordWithToken: Fetcher<CommonResponse, { url: string; body: { token: string; new_password: string; password_confirm: string } }> = ({ url, body }) => | |||
| post<CommonResponse>(url, { body }, { isPublicAPI: true }) | |||
| export const uploadRemoteFileInfo = (url: string, isPublic?: boolean) => { | |||
| return post<{ id: string; name: string; size: number; mime_type: string; url: string }>('/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic }) | |||
| } | |||
| @@ -340,6 +353,18 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') => | |||
| export const verifyResetPasswordCode = (body: { email: string; code: string; token: string }) => | |||
| post<CommonResponse & { is_valid: boolean; token: string }>('/forgot-password/validity', { body }) | |||
| export const sendWebAppEMailLoginCode = (email: string, language = 'en-US') => | |||
| post<CommonResponse & { data: string }>('/email-code-login', { body: { email, language } }, { isPublicAPI: true }) | |||
| export const webAppEmailLoginWithCode = (data: { email: string; code: string; token: string }) => | |||
| post<LoginResponse>('/email-code-login/validity', { body: data }, { isPublicAPI: true }) | |||
| export const sendWebAppResetPasswordCode = (email: string, language = 'en-US') => | |||
| post<CommonResponse & { data: string; message?: string; code?: string }>('/forgot-password', { body: { email, language } }, { isPublicAPI: true }) | |||
| export const verifyWebAppResetPasswordCode = (body: { email: string; code: string; token: string }) => | |||
| post<CommonResponse & { is_valid: boolean; token: string }>('/forgot-password/validity', { body }, { isPublicAPI: true }) | |||
| export const sendDeleteAccountCode = () => | |||
| get<CommonResponse & { data: string }>('/account/delete/verify') | |||
| @@ -214,6 +214,34 @@ export const fetchWebOAuth2SSOUrl = async (appCode: string, redirectUrl: string) | |||
| }) as Promise<{ url: string }> | |||
| } | |||
| export const fetchMembersSAMLSSOUrl = async (appCode: string, redirectUrl: string) => { | |||
| return (getAction('get', false))(getUrl('/enterprise/sso/members/saml/login', false, ''), { | |||
| params: { | |||
| app_code: appCode, | |||
| redirect_url: redirectUrl, | |||
| }, | |||
| }) as Promise<{ url: string }> | |||
| } | |||
| export const fetchMembersOIDCSSOUrl = async (appCode: string, redirectUrl: string) => { | |||
| return (getAction('get', false))(getUrl('/enterprise/sso/members/oidc/login', false, ''), { | |||
| params: { | |||
| app_code: appCode, | |||
| redirect_url: redirectUrl, | |||
| }, | |||
| }) as Promise<{ url: string }> | |||
| } | |||
| export const fetchMembersOAuth2SSOUrl = async (appCode: string, redirectUrl: string) => { | |||
| return (getAction('get', false))(getUrl('/enterprise/sso/members/oauth2/login', false, ''), { | |||
| params: { | |||
| app_code: appCode, | |||
| redirect_url: redirectUrl, | |||
| }, | |||
| }) as Promise<{ url: string }> | |||
| } | |||
| export const fetchAppMeta = async (isInstalledApp: boolean, installedAppId = '') => { | |||
| return (getAction('get', isInstalledApp))(getUrl('meta', isInstalledApp, installedAppId)) as Promise<AppMeta> | |||
| } | |||
| @@ -258,10 +286,13 @@ export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { c | |||
| return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true }) | |||
| } | |||
| export const fetchAccessToken = async (appCode: string, userId?: string) => { | |||
| export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { appCode: string, userId?: string, webAppAccessToken?: string | null }) => { | |||
| const headers = new Headers() | |||
| headers.append('X-App-Code', appCode) | |||
| const url = userId ? `/passport?user_id=${encodeURIComponent(userId)}` : '/passport' | |||
| const params = new URLSearchParams() | |||
| webAppAccessToken && params.append('web_app_access_token', webAppAccessToken) | |||
| userId && params.append('user_id', userId) | |||
| const url = `/passport?${params.toString()}` | |||
| return get(url, { headers }) as Promise<{ access_token: string }> | |||
| } | |||
| @@ -278,3 +309,7 @@ export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => { | |||
| return get<{ result: boolean }>(`/webapp/permission?appId=${appId}`) | |||
| } | |||
| export const getAppAccessModeByAppCode = (appCode: string) => { | |||
| return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appCode=${appCode}`) | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import { getAppAccessModeByAppCode } from './share' | |||
| const NAME_SPACE = 'webapp' | |||
| export const useAppAccessModeByCode = (code: string | null) => { | |||
| return useQuery({ | |||
| queryKey: [NAME_SPACE, 'appAccessMode', code], | |||
| queryFn: () => { | |||
| if (!code) | |||
| return null | |||
| return getAppAccessModeByAppCode(code) | |||
| }, | |||
| enabled: !!code, | |||
| }) | |||
| } | |||