feat: member invitation and activation (#535)

Co-authored-by: John Wang <takatost@gmail.com>
This commit is contained in:
KVOJJJin
2023-07-14 11:19:26 +08:00
committed by GitHub
parent 004b3caa43
commit cd51d3323b
51 changed files with 1235 additions and 329 deletions

View File

@ -0,0 +1,233 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useSearchParams } from 'next/navigation'
import cn from 'classnames'
import Link from 'next/link'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import style from './style.module.css'
import Button from '@/app/components/base/button'
import { SimpleSelect } from '@/app/components/base/select'
import { timezones } from '@/utils/timezone'
import { languageMaps, languages } from '@/utils/language'
import { activateMember, invitationCheck } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Loading from '@/app/components/base/loading'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
const ActivateForm = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const workspaceID = searchParams.get('workspace_id')
const email = searchParams.get('email')
const token = searchParams.get('token')
const checkParams = {
url: '/activate/check',
params: {
workspace_id: workspaceID,
email,
token,
},
}
const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, {
revalidateOnFocus: false,
})
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [timezone, setTimezone] = useState('Asia/Shanghai')
const [language, setLanguage] = useState('en-US')
const [showSuccess, setShowSuccess] = useState(false)
const showErrorMessage = (message: string) => {
Toast.notify({
type: 'error',
message,
})
}
const valid = () => {
if (!name.trim()) {
showErrorMessage(t('login.error.nameEmpty'))
return false
}
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password))
showErrorMessage(t('login.error.passwordInvalid'))
return true
}
const handleActivate = async () => {
if (!valid())
return
try {
await activateMember({
url: '/activate',
body: {
workspace_id: workspaceID,
email,
token,
name,
password,
interface_language: language,
timezone,
},
})
setShowSuccess(true)
}
catch {
recheck()
}
}
return (
<div className={
cn(
'flex flex-col items-center w-full grow items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
{!checkRes && <Loading/>}
{checkRes && !checkRes.is_valid && (
<div className="flex flex-col md:w-[400px]">
<div className="w-full mx-auto">
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">🤷</div>
<h2 className="text-[32px] font-bold text-gray-900">{t('login.invalid')}</h2>
</div>
<div className="w-full mx-auto mt-6">
<Button type='primary' className='w-full !fone-medium !text-sm'>
<a href="https://dify.ai">{t('login.explore')}</a>
</Button>
</div>
</div>
)}
{checkRes && checkRes.is_valid && !showSuccess && (
<div className='flex flex-col md:w-[400px]'>
<div className="w-full mx-auto">
<div className={`mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold ${style.logo}`}>
</div>
<h2 className="text-[32px] font-bold text-gray-900">
{`${t('login.join')} ${checkRes.workspace_name}`}
</h2>
<p className='mt-1 text-sm text-gray-600 '>
{`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`}
</p>
</div>
<div className="w-full mx-auto mt-6">
<div className="bg-white">
{/* username */}
<div className='mb-5'>
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.name')}
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('login.namePlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
/>
</div>
</div>
{/* password */}
<div className='mb-5'>
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.password')}
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
id="password"
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t('login.passwordPlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
/>
</div>
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
</div>
{/* language */}
<div className='mb-5'>
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.interfaceLanguage')}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<SimpleSelect
defaultValue={languageMaps.en}
items={languages}
onSelect={(item) => {
setLanguage(item.value as string)
}}
/>
</div>
</div>
{/* timezone */}
<div className='mb-4'>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
{t('login.timezone')}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<SimpleSelect
defaultValue={timezone}
items={timezones}
onSelect={(item) => {
setTimezone(item.value as string)
}}
/>
</div>
</div>
<div>
<Button
type='primary'
className='w-full !fone-medium !text-sm'
onClick={handleActivate}
>
{`${t('login.join')} ${checkRes.workspace_name}`}
</Button>
</div>
<div className="block w-hull mt-2 text-xs text-gray-600">
{t('login.license.tip')}
&nbsp;
<Link
className='text-primary-600'
target={'_blank'}
href='https://docs.dify.ai/community/open-source'
>{t('login.license.link')}</Link>
</div>
</div>
</div>
</div>
)}
{checkRes && checkRes.is_valid && showSuccess && (
<div className="flex flex-col md:w-[400px]">
<div className="w-full mx-auto">
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">
<CheckCircleIcon className='w-10 h-10 text-[#039855]' />
</div>
<h2 className="text-[32px] font-bold text-gray-900">
{`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`}
</h2>
</div>
<div className="w-full mx-auto mt-6">
<Button type='primary' className='w-full !fone-medium !text-sm'>
<a href="/signin">{t('login.activated')}</a>
</Button>
</div>
</div>
)}
</div>
)
}
export default ActivateForm

32
web/app/activate/page.tsx Normal file
View File

@ -0,0 +1,32 @@
import React from 'react'
import cn from 'classnames'
import Header from '../signin/_header'
import style from '../signin/page.module.css'
import ActivateForm from './activateForm'
const Activate = () => {
return (
<div className={cn(
style.background,
'flex w-full min-h-screen',
'sm:p-4 lg:p-8',
'gap-x-20',
'justify-center lg:justify-start',
)}>
<div className={
cn(
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
'space-between',
)
}>
<Header />
<ActivateForm />
<div className='px-8 py-6 text-sm font-normal text-gray-500'>
© {new Date().getFullYear()} Dify, Inc. All rights reserved.
</div>
</div>
</div>
)
}
export default Activate

View File

@ -0,0 +1,4 @@
.logo {
background: #fff center no-repeat url(./team-28x28.png);
background-size: 56px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -11,7 +11,7 @@ export const RFC_LOCALES = [
{ value: 'en-US', name: 'EN' },
{ value: 'zh-Hans', name: '简体中文' },
]
interface ISelectProps {
type ISelectProps = {
items: Array<{ value: string; name: string }>
value?: string
className?: string
@ -21,7 +21,7 @@ interface ISelectProps {
export default function Select({
items,
value,
onChange
onChange,
}: ISelectProps) {
const item = items.filter(item => item.value === value)[0]
@ -29,11 +29,12 @@ export default function Select({
<div className="w-56 text-right">
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex w-full justify-center items-center
rounded-lg px-2 py-1
text-gray-600 text-xs font-medium
border border-gray-200">
<GlobeAltIcon className="w-5 h-5 mr-2 " aria-hidden="true" />
<Menu.Button className="inline-flex w-full h-[44px]justify-center items-center
rounded-lg px-[10px] py-[6px]
text-gray-900 text-[13px] font-medium
border border-gray-200
hover:bg-gray-100">
<GlobeAltIcon className="w-5 h-5 mr-1" aria-hidden="true" />
{item?.name}
</Menu.Button>
</div>
@ -46,14 +47,14 @@ export default function Select({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-28 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Items className="absolute right-0 mt-2 w-[120px] origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-1 py-1 ">
{items.map((item) => {
return <Menu.Item key={item.value}>
{({ active }) => (
<button
className={`${active ? 'bg-gray-100' : ''
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-gray-700`}
onClick={(evt) => {
evt.preventDefault()
onChange && onChange(item.value)
@ -77,7 +78,7 @@ export default function Select({
export function InputSelect({
items,
value,
onChange
onChange,
}: ISelectProps) {
const item = items.filter(item => item.value === value)[0]
return (
@ -104,7 +105,7 @@ export function InputSelect({
{({ active }) => (
<button
className={`${active ? 'bg-gray-100' : ''
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
onClick={() => {
onChange && onChange(item.value)
}}
@ -122,4 +123,4 @@ export function InputSelect({
</Menu>
</div>
)
}
}

View File

@ -1,6 +1,6 @@
.logo-icon {
background: url(../assets/logo-icon.png) center center no-repeat;
background-size: contain;
background-size: 32px;
box-shadow: 0px 4px 6px -1px rgba(0, 0, 0, 0.05), 0px 2px 4px -2px rgba(0, 0, 0, 0.05);
}

View File

@ -34,7 +34,7 @@ export default function AccountAbout({
<div>
<div className={classNames(
s['logo-icon'],
'mx-auto mb-3 w-12 h-12 bg-white rounded border border-gray-200',
'mx-auto mb-3 w-12 h-12 bg-white rounded-xl border border-gray-200',
)} />
<div className={classNames(
s['logo-text'],

View File

@ -25,13 +25,19 @@ const inputClassName = `
text-sm font-normal text-gray-800
`
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function AccountPage() {
const { t } = useTranslation()
const { mutateUserProfile, userProfile, apps } = useAppContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
const { t } = useTranslation()
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const handleEditName = () => {
setEditNameModalVisible(true)
@ -52,6 +58,56 @@ export default function AccountPage() {
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
notify({
type: 'error',
message,
})
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password))
showErrorMessage(t('login.error.passwordInvalid'))
if (password !== confirmPassword)
showErrorMessage(t('common.account.notEqual'))
return true
}
const resetPasswordForm = () => {
setCurrentPassword('')
setPassword('')
setConfirmPassword('')
}
const handleSavePassowrd = async () => {
if (!valid())
return
try {
setEditing(true)
await updateUserProfile({
url: 'account/password',
body: {
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditPasswordModalVisible(false)
setEditing(false)
}
}
const renderAppItem = (item: IItem) => {
return (
<div className='flex px-3 py-1'>
@ -80,51 +136,105 @@ export default function AccountPage() {
<div className={titleClassName}>{t('common.account.email')}</div>
<div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
</div>
{
!!apps.length && (
<>
<div className='mb-6 border-[0.5px] border-gray-100' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
</div>
</>
)
}
{
editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className={s.modal}
>
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<input
className={inputClassName}
value={editName}
onChange={e => setEditName(e.target.value)}
<div className='mb-8'>
<div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
<div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
<Button className='font-medium !text-gray-700 !px-3 !py-[7px] !text-[13px]' onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
</div>
{!!apps.length && (
<>
<div className='mb-6 border-[0.5px] border-gray-100' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
<div className='flex justify-end mt-10'>
<Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing || !editName}
type='primary'
className='text-sm font-medium'
onClick={handleSaveName}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
</div>
</>
)}
{editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className={s.modal}
>
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<input
className={inputClassName}
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className='flex justify-end mt-10'>
<Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing || !editName}
type='primary'
className='text-sm font-medium'
onClick={handleSaveName}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)}
{editPasswordModalVisible && (
<Modal
isShow
onClose={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className={s.modal}
>
<div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
<input
type="password"
className={inputClassName}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
</>
)}
<div className='mt-8 text-sm font-medium text-gray-900'>
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
</div>
<input
type="password"
className={inputClassName}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
<input
type="password"
className={inputClassName}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className='flex justify-end mt-10'>
<Button className='mr-2 text-sm font-medium' onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing}
type='primary'
className='text-sm font-medium'
onClick={handleSavePassowrd}
>
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
</Button>
</div>
</Modal>
)}
</>
)
}

View File

@ -1,6 +1,7 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import cn from 'classnames'
import { AtSymbolIcon, CubeTransparentIcon, GlobeAltIcon, UserIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { GlobeAltIcon as GlobalAltIconSolid, UserIcon as UserIconSolid, UsersIcon as UsersIconSolid } from '@heroicons/react/24/solid'
import AccountPage from './account-page'
@ -18,6 +19,10 @@ const iconClassName = `
w-4 h-4 ml-3 mr-2
`
const scrolledClassName = `
border-b shadow-xs bg-white/[.98]
`
type IAccountSettingProps = {
onCancel: () => void
activeTab?: string
@ -78,6 +83,22 @@ export default function AccountSetting({
],
},
]
const scrollRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
const scrollHandle = (e: any) => {
if (e.target.scrollTop > 0)
setScrolled(true)
else
setScrolled(false)
}
useEffect(() => {
const targetElement = scrollRef.current
targetElement?.addEventListener('scroll', scrollHandle)
return () => {
targetElement?.removeEventListener('scroll', scrollHandle)
}
}, [])
return (
<Modal
@ -115,29 +136,19 @@ export default function AccountSetting({
}
</div>
</div>
<div className='w-[520px] h-[580px] px-6 py-4 overflow-y-auto'>
<div className='flex items-center justify-between h-6 mb-8 text-base font-medium text-gray-900 '>
<div ref={scrollRef} className='relative w-[520px] h-[580px] pb-4 overflow-y-auto'>
<div className={cn('sticky top-0 px-6 py-4 flex items-center justify-between h-14 mb-4 bg-white text-base font-medium text-gray-900', scrolled && scrolledClassName)}>
{[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div>
{
activeMenu === 'account' && <AccountPage />
}
{
activeMenu === 'members' && <MembersPage />
}
{
activeMenu === 'integrations' && <IntegrationsPage />
}
{
activeMenu === 'language' && <LanguagePage />
}
{
activeMenu === 'provider' && <ProviderPage />
}
{
activeMenu === 'data-source' && <DataSourcePage />
}
<div className='px-6'>
{activeMenu === 'account' && <AccountPage />}
{activeMenu === 'members' && <MembersPage />}
{activeMenu === 'integrations' && <IntegrationsPage />}
{activeMenu === 'language' && <LanguagePage />}
{activeMenu === 'provider' && <ProviderPage />}
{activeMenu === 'data-source' && <DataSourcePage />}
</div>
</div>
</div>
</Modal>

View File

@ -30,6 +30,7 @@ const MembersPage = () => {
const { userProfile } = useAppContext()
const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers)
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationLink, setInvitationLink] = useState('')
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email
@ -93,8 +94,9 @@ const MembersPage = () => {
inviteModalVisible && (
<InviteModal
onCancel={() => setInviteModalVisible(false)}
onSend={() => {
onSend={(url) => {
setInvitedModalVisible(true)
setInvitationLink(url)
mutate()
}}
/>
@ -103,6 +105,7 @@ const MembersPage = () => {
{
invitedModalVisible && (
<InvitedModal
invitationLink={invitationLink}
onCancel={() => setInvitedModalVisible(false)}
/>
)

View File

@ -3,16 +3,16 @@ import { useState } from 'react'
import { useContext } from 'use-context-selector'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import s from './index.module.css'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import s from './index.module.css'
import { inviteMember } from '@/service/common'
import { emailRegex } from '@/config'
import { ToastContext } from '@/app/components/base/toast'
interface IInviteModalProps {
onCancel: () => void,
onSend: () => void,
type IInviteModalProps = {
onCancel: () => void
onSend: (url: string) => void
}
const InviteModal = ({
onCancel,
@ -25,16 +25,16 @@ const InviteModal = ({
const handleSend = async () => {
if (emailRegex.test(email)) {
try {
const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin'} })
const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin' } })
if (res.result === 'success') {
onCancel()
onSend()
onSend(res.invite_url)
}
} catch (e) {
}
} else {
catch (e) {}
}
else {
notify({ type: 'error', message: t('common.members.emailInvalid') })
}
}
@ -51,15 +51,15 @@ const InviteModal = ({
<div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
<input
className='
block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
appearance-none text-sm text-gray-900 rounded-lg
'
value={email}
onChange={e => setEmail(e.target.value)}
placeholder={t('common.members.emailPlaceholder') || ''}
/>
<Button
className='w-full text-sm font-medium'
<Button
className='w-full text-sm font-medium'
onClick={handleSend}
type='primary'
>

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -2,4 +2,20 @@
padding: 32px !important;
width: 480px !important;
background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important;
}
.copyIcon {
background-image: url(./assets/copy.svg);
background-position: center;
background-repeat: no-repeat;
}
.copyIcon:hover {
background-image: url(./assets/copy-hover.svg);
background-position: center;
background-repeat: no-repeat;
}
.copyIcon.copied {
background-image: url(./assets/copied.svg);
}

View File

@ -1,15 +1,17 @@
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import InvitationLink from './invitation-link'
import s from './index.module.css'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import s from './index.module.css'
interface IInvitedModalProps {
onCancel: () => void,
type IInvitedModalProps = {
invitationLink: string
onCancel: () => void
}
const InvitedModal = ({
onCancel
invitationLink,
onCancel,
}: IInvitedModalProps) => {
const { t } = useTranslation()
@ -27,10 +29,14 @@ const InvitedModal = ({
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div>
<div className='mb-1 text-xl font-semibold text-gray-900'>{t('common.members.invitationSent')}</div>
<div className='mb-10 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
<div className='mb-5 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
<div className='mb-9'>
<div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.invitationLink')}</div>
<InvitationLink value={invitationLink} />
</div>
<div className='flex justify-end'>
<Button
className='w-[96px] text-sm font-medium'
<Button
className='w-[96px] text-sm font-medium'
onClick={onCancel}
type='primary'
>
@ -42,4 +48,4 @@ const InvitedModal = ({
)
}
export default InvitedModal
export default InvitedModal

View File

@ -0,0 +1,63 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { t } from 'i18next'
import s from './index.module.css'
import Tooltip from '@/app/components/base/tooltip'
import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
type IInvitationLinkProps = {
value?: string
}
const InvitationLink = ({
value = '',
}: IInvitationLinkProps) => {
const [isCopied, setIsCopied] = useState(false)
const [_, copy] = useCopyToClipboard()
const copyHandle = useCallback(() => {
copy(value)
setIsCopied(true)
}, [value, copy])
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => {
setIsCopied(false)
}, 1000)
return () => {
clearTimeout(timeout)
}
}
}, [isCopied])
return (
<div className='flex rounded-lg bg-gray-100 hover:bg-gray-100 border border-gray-200 py-2 items-center'>
<div className="flex items-center flex-grow h-5">
<div className='flex-grow bg-gray-100 text-[13px] relative h-full'>
<Tooltip
selector="top-uniq"
content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
className='z-10'
>
<div className='absolute top-0 left-0 w-full pl-2 pr-2 truncate cursor-pointer r-0' onClick={copyHandle}>{value}</div>
</Tooltip>
</div>
<div className="flex-shrink-0 h-4 bg-gray-200 border" />
<Tooltip
selector="top-uniq"
content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
className='z-10'
>
<div className="px-0.5 flex-shrink-0">
<div className={`box-border w-[30px] h-[30px] flex items-center justify-center rounded-lg hover:bg-gray-100 cursor-pointer ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle}>
</div>
</div>
</Tooltip>
</div>
</div>
)
}
export default InvitationLink

View File

@ -1,10 +1,10 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Toast from '../components/base/toast'
import Button from '@/app/components/base/button'
import { setup } from '@/service/common'
const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
@ -40,36 +40,37 @@ const InstallForm = () => {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
if (!validPassword.test(password))
showErrorMessage(t('login.error.passwordInvalid'))
}
return true
}
const handleSetting = async () => {
if (!valid()) return
if (!valid())
return
await setup({
body: {
email,
name,
password
}
password,
},
})
router.push('/signin')
}
return (
<>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="text-3xl font-normal text-gray-900">{t('login.setAdminAccount')}</h2>
<h2 className="text-[32px] font-bold text-gray-900">{t('login.setAdminAccount')}</h2>
<p className='
mt-2 text-sm text-gray-600
mt-1 text-sm text-gray-600
'>{t('login.setAdminAccountDesc')}</p>
</div>
<div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white ">
<form className="space-y-6" onSubmit={() => { }}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
<form onSubmit={() => { }}>
<div className='mb-5'>
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.email')}
</label>
<div className="mt-1">
@ -78,13 +79,14 @@ const InstallForm = () => {
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
placeholder={t('login.emailPlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
/>
</div>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
<div className='mb-5'>
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.name')}
</label>
<div className="mt-1 relative rounded-md shadow-sm">
@ -93,13 +95,14 @@ const InstallForm = () => {
type="text"
value={name}
onChange={e => setName(e.target.value)}
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
placeholder={t('login.namePlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
<div className='mb-5'>
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.password')}
</label>
<div className="mt-1 relative rounded-md shadow-sm">
@ -108,7 +111,8 @@ const InstallForm = () => {
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
placeholder={t('login.passwordPlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
/>
</div>
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
@ -123,29 +127,21 @@ const InstallForm = () => {
</div>
</div>
</div> */}
{/* agree to our Terms and Privacy Policy. */}
<div className="block mt-6 text-xs text-gray-600">
{t('login.tosDesc')}
&nbsp;
<Link
className='text-primary-600'
target={'_blank'}
href='https://docs.dify.ai/user-agreement/terms-of-service'
>{t('login.tos')}</Link>
&nbsp;&&nbsp;
<Link
className='text-primary-600'
target={'_blank'}
href='https://langgenius.ai/privacy-policy'
>{t('login.pp')}</Link>
</div>
<div>
<Button type='primary' onClick={handleSetting}>
<Button type='primary' className='w-full !fone-medium !text-sm' onClick={handleSetting}>
{t('login.installBtn')}
</Button>
</div>
</form>
<div className="block w-hull mt-2 text-xs text-gray-600">
{t('login.license.tip')}
&nbsp;
<Link
className='text-primary-600'
target={'_blank'}
href='https://docs.dify.ai/community/open-source'
>{t('login.license.link')}</Link>
</div>
</div>
</div>
</>

View File

@ -1,16 +1,10 @@
'use client'
import React from 'react'
import { useContext } from 'use-context-selector'
import style from './page.module.css'
import Select, { LOCALES } from '@/app/components/base/select/locale'
import { type Locale } from '@/i18n'
import I18n from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n/client'
import { useContext } from 'use-context-selector'
type IHeaderProps = {
locale: string
}
const Header = () => {
const { locale, setLocaleOnClient } = useContext(I18n)

View File

@ -2,9 +2,9 @@
import React from 'react'
import { useSearchParams } from 'next/navigation'
import cn from 'classnames'
import NormalForm from './normalForm'
import OneMoreStep from './oneMoreStep'
import classNames from 'classnames'
const Forms = () => {
const searchParams = useSearchParams()
@ -19,7 +19,7 @@ const Forms = () => {
}
}
return <div className={
classNames(
cn(
'flex flex-col items-center w-full grow items-center justify-center',
'px-6',
'md:px-[108px]',
@ -28,7 +28,6 @@ const Forms = () => {
<div className='flex flex-col md:w-[400px]'>
{getForm()}
</div>
</div>
}

View File

@ -2,16 +2,15 @@
import React, { useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { IS_CE_EDITION } from '@/config'
import classNames from 'classnames'
import useSWR from 'swr'
import Link from 'next/link'
import Toast from '../components/base/toast'
import style from './page.module.css'
// import Tooltip from '@/app/components/base/tooltip/index'
import Toast from '../components/base/toast'
import { IS_CE_EDITION, apiPrefix } from '@/config'
import Button from '@/app/components/base/button'
import { login, oauth } from '@/service/common'
import { apiPrefix } from '@/config'
const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
@ -91,8 +90,9 @@ const NormalForm = () => {
remember_me: true,
},
})
router.push('/')
} finally {
router.push('/apps')
}
finally {
setIsLoading(false)
}
}
@ -132,8 +132,8 @@ const NormalForm = () => {
return (
<>
<div className="w-full mx-auto">
<h2 className="text-3xl font-normal text-gray-900">{t('login.pageTitle')}</h2>
<p className='mt-2 text-sm text-gray-600 '>{t('login.welcome')}</p>
<h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2>
<p className='mt-1 text-sm text-gray-600'>{t('login.welcome')}</p>
</div>
<div className="w-full mx-auto mt-8">
@ -145,7 +145,7 @@ const NormalForm = () => {
<Button
type='default'
disabled={isLoading}
className='w-full'
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
>
<>
<span className={
@ -154,7 +154,7 @@ const NormalForm = () => {
'w-5 h-5 mr-2',
)
} />
<span className="truncate">{t('login.withGitHub')}</span>
<span className="truncate text-gray-800">{t('login.withGitHub')}</span>
</>
</Button>
</a>
@ -164,7 +164,7 @@ const NormalForm = () => {
<Button
type='default'
disabled={isLoading}
className='w-full'
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
>
<>
<span className={
@ -173,7 +173,7 @@ const NormalForm = () => {
'w-5 h-5 mr-2',
)
} />
<span className="truncate">{t('login.withGoogle')}</span>
<span className="truncate text-gray-800">{t('login.withGoogle')}</span>
</>
</Button>
</a>
@ -192,9 +192,9 @@ const NormalForm = () => {
</div>
</div> */}
<form className="space-y-6" onSubmit={() => { }}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
<form onSubmit={() => { }}>
<div className='mb-5'>
<label htmlFor="email" className="my-2 block text-sm font-medium text-gray-900">
{t('login.email')}
</label>
<div className="mt-1">
@ -204,13 +204,14 @@ const NormalForm = () => {
id="email"
type="email"
autoComplete="email"
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
placeholder={t('login.emailPlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
/>
</div>
</div>
<div>
<label htmlFor="password" className="flex items-center justify-between text-sm font-medium text-gray-700">
<div className='mb-4'>
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
<span>{t('login.password')}</span>
{/* <Tooltip
selector='forget-password'
@ -235,10 +236,8 @@ const NormalForm = () => {
onChange={e => setPassword(e.target.value)}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`appearance-none block w-full px-3 py-2
border border-gray-300
focus:outline-none focus:ring-indigo-500 focus:border-indigo-500
rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10`}
placeholder={t('login.passwordPlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<button
@ -252,18 +251,19 @@ const NormalForm = () => {
</div>
</div>
<div>
<div className='mb-2'>
<Button
type='primary'
onClick={handleEmailPasswordLogin}
disabled={isLoading}
className="w-full !fone-medium !text-sm"
>{t('login.signBtn')}</Button>
</div>
</form>
</>
}
{/* agree to our Terms and Privacy Policy. */}
<div className="block mt-6 text-xs text-gray-600">
<div className="w-hull text-center block mt-2 text-xs text-gray-600">
{t('login.tosDesc')}
&nbsp;
<Link

View File

@ -1,6 +1,7 @@
'use client'
import React, { useEffect, useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import useSWR from 'swr'
import { useRouter } from 'next/navigation'
import Button from '@/app/components/base/button'
@ -74,14 +75,14 @@ const OneMoreStep = () => {
return (
<>
<div className="w-full mx-auto">
<h2 className="text-3xl font-normal text-gray-900">{t('login.oneMoreStep')}</h2>
<p className='mt-2 text-sm text-gray-600 '>{t('login.createSample')}</p>
<h2 className="text-[32px] font-bold text-gray-900">{t('login.oneMoreStep')}</h2>
<p className='mt-1 text-sm text-gray-600 '>{t('login.createSample')}</p>
</div>
<div className="w-full mx-auto mt-8">
<div className="space-y-6 bg-white">
<div className="">
<label className="flex items-center justify-between text-sm font-medium text-gray-900">
<div className="w-full mx-auto mt-6">
<div className="bg-white">
<div className="mb-5">
<label className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.invitationCode')}
<Tooltip
clickable
@ -103,16 +104,16 @@ const OneMoreStep = () => {
id="invitation_code"
value={state.invitation_code}
type="text"
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-600 focus:border-primary-600 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
placeholder={t('login.invitationCodePlaceholder') || ''}
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
onChange={(e) => {
dispatch({ type: 'invitation_code', value: e.target.value.trim() })
}}
/>
</div>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
<div className='mb-5'>
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
{t('login.interfaceLanguage')}
</label>
<div className="relative mt-1 rounded-md shadow-sm">
@ -125,8 +126,7 @@ const OneMoreStep = () => {
/>
</div>
</div>
<div>
<div className='mb-4'>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
{t('login.timezone')}
</label>
@ -143,6 +143,7 @@ const OneMoreStep = () => {
<div>
<Button
type='primary'
className='w-full !fone-medium !text-sm'
disabled={state.formState === 'processing'}
onClick={() => {
dispatch({ type: 'formState', value: 'processing' })
@ -151,6 +152,15 @@ const OneMoreStep = () => {
{t('login.go')}
</Button>
</div>
<div className="block w-hull mt-2 text-xs text-gray-600">
{t('login.license.tip')}
&nbsp;
<Link
className='text-primary-600'
target={'_blank'}
href='https://docs.dify.ai/community/open-source'
>{t('login.license.link')}</Link>
</div>
</div>
</div>
</>

View File

@ -1,24 +1,23 @@
import React from 'react'
import cn from 'classnames'
import Forms from './forms'
import Header from './_header'
import style from './page.module.css'
import classNames from 'classnames'
const SignIn = () => {
return (
<>
<div className={classNames(
<div className={cn(
style.background,
'flex w-full min-h-screen',
'sm:p-4 lg:p-8',
'gap-x-20',
'justify-center lg:justify-start'
'justify-center lg:justify-start',
)}>
<div className={
classNames(
cn(
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
'space-between'
'space-between',
)
}>
<Header />