feat: new authenticate UI (#12172)

* enhance(ui): login form

* enhance(ui): add localization support with translate and locale management

* enhance(ui): WIP implement new authentication forms with context management

* enhance(ui): add password visibility toggle to input row

* enhance(ui): adjust padding for password visibility toggle

* enhance(i18n): implement internationalization support for authentication UI

* enhance(ui): implement sign in and sign up functionality with loading state

* enhance(ui): add session management and error handling in login form

* enhance(ui): add confirm code form and enhance authentication flow

* enhance(ui): improve sign-in flow and confirm code handling

* enhance(ui): add warning variant to alerts and improve error handling

* enhance(ui): implement countdown timer for code resend functionality

* enhance(ui): implement countdown timer for password reset and enhance login flow

* enhance(ui): export authentication and enhance UI components

* enhance(ui): integrate new login component and refresh token handling

* chore: clear amplify related codes

* enhance(i18n): normalize language codes and update locale handling

* enhance(auth): add multilingual support for signup and password reset flows

* enhance(ui): update login styles to inherit text color

* enhance(ui): adjust input color variables for improved accessibility

* enhance(auth): add password policy validation and tips in multiple languages

* enhance(ui): improve localization handling and update alert styles

* enhance(mobile): enhance login modal styling and accessibility

* fix(ui): update password validation regex for special characters

* enhance(ui): add padding to card header in login dialog

---------

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
This commit is contained in:
Charlie
2025-10-28 16:55:43 +08:00
committed by GitHub
parent 5b35a9ee49
commit a0a19a91fa
27 changed files with 2365 additions and 251 deletions

View File

@@ -12,6 +12,8 @@ const alertVariants = cva(
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-yellow-600 text-yellow-600 dark:text-yellow-500/70 [&>svg]:text-yellow-600',
},
},
defaultVariants: {

View File

@@ -9,6 +9,11 @@
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="./index.tsx" type="module"></script>
<style>
a {
cursor: pointer;
}
</style>
</head>
<body>
<div id="app"></div>

View File

@@ -2,22 +2,63 @@ import '../src/index.css'
import { setupGlobals } from '../src/ui'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { init, t } from '../src/amplify/core'
// @ts-ignore
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { LoginForm, ResetPasswordForm, SignupForm, ConfirmWithCodeForm } from '../src/amplify/ui'
import { AuthFormRootContext } from '../src/amplify/core'
// bootstrap
setupGlobals()
init()
function App() {
const [errors, setErrors] = React.useState<string | null>(null)
const [currentTab, setCurrentTab] = React.useState<'login' | 'reset' | 'signup' | 'confirm-code' | any>('login')
const onSessionCallback = React.useCallback((session: any) => {
console.log('==>>session callback:', session)
}, [])
React.useEffect(() => {
setErrors(null)
}, [currentTab])
let content = null
// support passing object with type field
let _currentTab = currentTab?.type ? currentTab.type : currentTab
let _currentTabProps = currentTab?.props || {}
switch (_currentTab) {
case 'login':
content = <LoginForm/>
break
case 'reset':
content = <ResetPasswordForm/>
break
case 'signup':
content = <SignupForm/>
break
case 'confirm-code':
content = <ConfirmWithCodeForm {..._currentTabProps}/>
break
}
return (
<main className={'p-8'}>
<h1 className={'text-red-500 mb-8'}>
Hello, Logseq UI :)
</h1>
<Button asChild>
<a href={'https://google.com'} target={'_blank'}>go to google.com</a>
</Button>
<main className={'h-screen flex flex-col justify-center items-center gap-4'}>
<AuthFormRootContext.Provider value={{
errors, setErrors, setCurrentTab,
onSessionCallback
}}>
<Card className={'sm:w-96'}>
<CardHeader>
<CardTitle className={'capitalize'}>{t(_currentTab)?.replace('-', ' ')}</CardTitle>
</CardHeader>
<CardContent>
{content}
</CardContent>
</Card>
</AuthFormRootContext.Provider>
</main>
)
}

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle-group": "^1.1.7",
"@radix-ui/react-tooltip": "^1.2.4",
"@silk-hq/components": "^0.9.10",
"aws-amplify": "^6.15.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
@@ -70,6 +71,7 @@
"@types/prop-types": "^15",
"@types/react": "17",
"@types/react-dom": "17",
"buffer": "^5.5.0",
"parcel": "2.8.3",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",

View File

@@ -0,0 +1,26 @@
import { Amplify } from 'aws-amplify'
import { createContext, useContext } from 'react'
import { translate, setNSDicts, setLocale } from '../i18n'
export const AuthFormRootContext = createContext<any>(null)
export const useAuthFormState = () => {
return useContext(AuthFormRootContext)
}
export function t(key: string, ...args: any) {
return translate('amplify', key, ...args)
}
export function init({ lang, authCognito }: any) {
// Load default language
setNSDicts('amplify', require('./lang').default)
if (lang) setLocale(lang)
Amplify.configure({
Auth: {
Cognito: {
...authCognito,
loginWith: { email: true }
}
}
})
}

View File

@@ -0,0 +1,8 @@
import * as Auth from 'aws-amplify/auth'
import { init } from './core'
import { LSAuthenticator } from './ui'
export {
init, Auth,
LSAuthenticator
}

View File

@@ -0,0 +1,114 @@
export default {
'en': {
'signup': 'Sign Up',
'reset-password': 'Reset Password',
'confirm-code': 'Confirm Code',
'CODE_ON_THE_WAY_TIP': 'Your code is on the way. To log in, enter the code we sent you. It may take a minute to arrive.',
'PW_POLICY_TIP': '1. at least 8 characters.\n' +
'2. must have lowercase characters.\n' +
'3. must have uppercase characters.\n' +
'4. must have symbol characters.',
},
'zh-cn': {
'login': '登录',
'signup': '注册',
'reset-password': '重置密码',
'confirm-code': '确认验证码',
'PW_POLICY_TIP': '1. 密码长度至少8个字符\n' +
'2. 密码必须包含小写字母\n' +
'3. 密码必须包含大写字母\n' +
'4. 密码必须包含特殊字符',
'CODE_ON_THE_WAY_TIP': '验证码已发送。请输入我们发送给您的验证码以登录。可能需要一分钟才能收到。',
'Sign in to your account': '登录到您的账户',
'Email': '电子邮箱',
'Password': '密码',
'Sign in': '登录',
'Confirm': '确认',
'Don\'t have an account?': '还没有账户?',
'Sign up': '注册',
'or': '或 ',
'Forgot your password?': '忘记密码?',
'Create account': '创建您的账户',
'Username': '用户名',
'Confirm Password': '确认密码',
'New Password': '新密码',
'By signing up, you agree to our': '注册即表示您同意我们的 ',
'Terms of Service': '服务条款',
'Privacy Policy': '隐私政策',
'Already have an account?': '已经有账户?',
'Reset password': '重置您的密码',
'Enter the code sent to your email': '输入发送到您邮箱的验证码',
'Send code': '发送验证码',
'Resend code': '重新发送验证码',
'Back to login': '返回登录',
'Enter your email': '请输入您的电子邮箱'
},
'zh-hant': {
'login': '登入',
'signup': '註冊',
'reset-password': '重置密碼',
'confirm-code': '確認驗證碼',
'CODE_ON_THE_WAY_TIP': '驗證碼已發送。請輸入我們發送給您的驗證碼以登入。可能需要一分鐘才能收到。',
'PW_POLICY_TIP': '1. 密碼長度至少8個字符\n' +
'2. 密碼必須包含小寫字母\n' +
'3. 密碼必須包含大寫字母\n' +
'4. 密碼必須包含特殊字符',
'Sign in to your account': '登入到您的帳戶',
'Email': '電子郵箱',
'Password': '密碼',
'Sign in': '登入',
'Confirm': '確認',
'Don\'t have an account?': '還沒有帳戶?',
'Sign up': '註冊',
'or': '或 ',
'Forgot your password?': '忘記密碼?',
'Create account': '創建您的帳戶',
'Username': '用戶名',
'Confirm Password': '確認密碼',
'New Password': '新密碼',
'By signing up, you agree to our': '註冊即表示您同意我們的 ',
'Terms of Service': '服務條款',
'Privacy Policy': '隱私政策',
'Already have an account?': '已經有帳戶?',
'Reset password': '重置您的密碼',
'Enter the code sent to your email': '輸入發送到您郵箱的驗證碼',
'Send code': '發送驗證碼',
'Resend code': '重新發送驗證碼',
'Back to login': '返回登入',
'Enter your email': '請輸入您的電子郵箱'
},
'ja': {
'login': 'ログイン',
'signup': 'サインアップ',
'reset-password': 'パスワードをリセットする',
'confirm-code': 'コードを確認する',
'CODE_ON_THE_WAY_TIP': 'コードが送信されました。ログインするには、送信したコードを入力してください。届くまでに1分ほどかかる場合があります。',
'PW_POLICY_TIP': '1. パスワードは8文字以上であること。\n' +
'2. パスワードには小文字を含める必要があります。\n' +
'3. パスワードには大文字を含める必要があります。\n' +
'4. パスワードには記号を含める必要があります。',
'Sign in to your account': 'アカウントにサインイン',
'Email': 'メール',
'Password': 'パスワード',
'Sign in': 'サインイン',
'Confirm': '確認',
'Don\'t have an account?': 'アカウントをお持ちでないですか?',
'Sign up': 'サインアップ',
'or': 'または ',
'Forgot your password?': 'パスワードをお忘れですか?',
'Create account': 'アカウントを作成する',
'Username': 'ユーザー名',
'New Password': '新しいパスワード',
'Confirm Password': 'パスワードを確認する',
'By signing up, you agree to our': 'サインアップすることで、あなたは私たちの ',
'Terms of Service': '利用規約',
'Privacy Policy': 'プライバシーポリシー',
'Already have an account? ': 'すでにアカウントをお持ちですか?',
'Reset password': 'パスワードをリセットする',
'Enter the code sent to your email': 'メールに送信されたコードを入力してください',
'Send code': 'コードを送信',
'Resend code': 'コードを再送信',
'Back to login': 'ログインに戻る',
'Enter your email': 'メールアドレスを入力してください'
}
}

View File

@@ -0,0 +1,710 @@
import { Button } from '@/components/ui/button'
import { Input, InputProps } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { FormHTMLAttributes, useEffect, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircleIcon, Loader2Icon, LucideEye, LucideEyeClosed, LucideX } from 'lucide-react'
import { AuthFormRootContext, t, useAuthFormState } from './core'
import * as Auth from 'aws-amplify/auth'
import { Skeleton } from '@/components/ui/skeleton'
import * as React from 'react'
function ErrorTip({ error, removeError }: {
error: string | { variant?: 'warning' | 'destructive', title?: string, message: string | any },
removeError?: () => void
}) {
if (!error) return null
if (typeof error === 'string') {
error = { message: error }
}
return (
<Alert variant={error.variant || 'destructive'} className={'relative'}>
<AlertCircleIcon size={18}/>
{error.title && <AlertTitle>{error.title}</AlertTitle>}
<AlertDescription>
<p>
{(typeof error.message === 'string' ? error.message : JSON.stringify(error.message))?.split('\n')
.map((line: string, idx: number) => {
return <span key={idx}>{line}<br/></span>
})}
</p>
</AlertDescription>
<a className={'close absolute right-0 top-0 opacity-50 hover:opacity-80 p-2'}
onClick={() => removeError?.()}>
<LucideX size={16}/>
</a>
</Alert>
)
}
function InputRow(
props: InputProps & { label: string | React.ReactNode },
) {
const { errors, setErrors } = useAuthFormState()
const { label, type, ...rest } = props
const isPassword = type === 'password'
const error = props.name && errors?.[props.name]
const [localType, setLocalType] = useState<string>(type || 'text')
const [showPassword, setShowPassword] = useState<boolean>(false)
const removeError = () => {
if (props.name && errors?.[props.name]) {
const newErrors = { ...errors }
delete newErrors[props.name]
setErrors(newErrors)
}
}
return (
<div className={'relative w-full flex flex-col gap-3 pb-1'}>
<Label htmlFor={props.id}>
{label}
</Label>
<Input type={localType} {...rest as any} />
{isPassword && (
<a className={'absolute px-2 right-1 top-7 py-3 flex items-center opacity-50 hover:opacity-80 select-none'}
onClick={() => {
setShowPassword(!showPassword)
setLocalType(showPassword ? 'password' : 'text')
}}
>
{showPassword ? <LucideEye size={14}/> : <LucideEyeClosed size={14}/>}
</a>
)}
{error &&
<div className={'pt-1'}>
<ErrorTip error={error} removeError={removeError}/>
</div>
}
</div>
)
}
function FormGroup(props: FormHTMLAttributes<any>) {
const { className, children, ...reset } = props
return (
<form className={cn('relative flex flex-col justify-center items-center gap-4 w-full', className)}
{...reset}>
{children}
</form>
)
}
// 1. Password must be at least 8 characters
// 2. Password must have lowercase characters
// 3. Password must have uppercase characters
// 4. Password must have symbol characters
function validatePasswordPolicy(password: string) {
if (!password ||
password.length < 8 ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password) ||
!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`]/.test(password)
) {
throw new Error(t('PW_POLICY_TIP'))
}
}
function useCountDown() {
const [countDownNum, setCountDownNum] = useState<number>(0)
const startCountDown = () => {
setCountDownNum(60)
const interval = setInterval(() => {
setCountDownNum((num) => {
if (num <= 1) {
clearInterval(interval)
return 0
}
return num - 1
})
}, 1000)
}
useEffect(() => {
return () => {
setCountDownNum(0)
}
}, [])
return { countDownNum, startCountDown, setCountDownNum }
}
export function LoginForm() {
const { setErrors, setCurrentTab, onSessionCallback, userSessionRender } = useAuthFormState()
const [loading, setLoading] = useState<boolean>(false)
const [sessionUser, setSessionUser] = useState<any>(null)
const loadSession = async () => {
try {
const ret = await Auth.fetchAuthSession()
if (!ret?.userSub) throw new Error('no session')
const user = await Auth.getCurrentUser()
onSessionCallback?.({ ...ret, user })
const tokens = ret.tokens
setSessionUser({
...user, signInUserSession: {
idToken: { jwtToken: tokens?.idToken?.toString() },
accessToken: { jwtToken: tokens?.accessToken.toString() },
refreshToken: null
}
})
await (new Promise(resolve => setTimeout(resolve, 100)))
} catch (e) {
console.warn('no current session:', e)
setSessionUser(false)
}
}
useEffect(() => {
// check current auth session
loadSession()
}, [])
if (sessionUser === null) {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]"/>
<Skeleton className="h-4 w-[200px]"/>
</div>)
}
const signOut = async () => {
await Auth.signOut()
setSessionUser(false)
setErrors(null)
}
if (sessionUser?.username) {
if (userSessionRender) {
if (typeof userSessionRender === 'function') {
return userSessionRender({ sessionUser, signOut })
}
return userSessionRender
}
return (
<div className={'w-full text-center'}>
<p className={'mb-4'}>{t('You are already logged in as')} <strong>{sessionUser.username}</strong></p>
<Button variant={'secondary'} className={'w-full'} onClick={signOut}>
{t('Sign out')}
</Button>
</div>
)
}
return (
<FormGroup onSubmit={async (e) => {
setErrors(null)
e.preventDefault()
// get submit form input data
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData.entries())
// sign in logic here
try {
setLoading(true)
const username = (data.email as string)?.trim()
const ret = await Auth.signIn({ username, password: data.password as string })
const nextStep = ret?.nextStep?.signInStep
if (!nextStep) throw new Error(JSON.stringify(ret))
switch (nextStep) {
case 'CONFIRM_SIGN_UP':
case 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE':
case 'CONFIRM_SIGN_IN_WITH_TOTP_CODE':
setCurrentTab({ type: 'confirm-code', props: { user: { ...ret, username }, nextStep } })
return
case 'RESET_PASSWORD':
setCurrentTab({ type: 'reset-password', props: { user: { ...ret, username }, nextStep } })
return
case 'DONE':
// signed in
await loadSession()
return
default:
throw new Error('Unsupported sign-in step: ' + nextStep)
}
} catch (e) {
setErrors({ password: { message: (e as Error).message, title: t('Bad Response.') } })
console.error(e)
} finally {
setLoading(false)
}
}}>
<InputRow id="email" type="text" required={true} name="email" autoFocus={true} label={t('Email')}/>
<InputRow id="password" type="password" required={true} name="password" label={t('Password')}/>
<div className={'w-full'}>
<Button type="submit" disabled={loading} className={'w-full'}>
{loading && <Loader2Icon className="animate-spin mr-1" size={16}/>}
{t('Sign in')}
</Button>
<p className={'pt-4 text-center'}>
<span className={'text-sm'}>
<span className={'opacity-50'}>{t('Don\'t have an account?')} </span>
<a
onClick={() => setCurrentTab('signup')}
className={'underline opacity-60 hover:opacity-80'}
>{t('Sign up')}</a>
<br/>
<span className={'opacity-50'}>{t('or')} &nbsp;</span>
</span>
<a onClick={() => {
setCurrentTab('reset-password')
}} className={'text-sm opacity-60 hover:opacity-80 underline'}>
{t('Forgot your password?')}
</a>
</p>
</div>
</FormGroup>
)
}
export function SignupForm() {
const { setCurrentTab, setErrors } = useAuthFormState()
const [loading, setLoading] = useState<boolean>(false)
return (
<>
<FormGroup onSubmit={async (e) => {
setErrors(null)
e.preventDefault()
// get submit form input data
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData.entries()) as any
try {
validatePasswordPolicy(data.password)
} catch (e) {
setErrors({
password: {
message: (e as Error).message,
title: t('Invalid Password')
}
})
return
}
if (data.password !== data.confirm_password) {
setErrors({
confirm_password: {
message: t('Passwords do not match.'),
title: t('Invalid Password')
}
})
return
}
try {
setLoading(true)
const ret = await Auth.signUp({
username: data.username as string,
password: data.password as string,
options: {
userAttributes: {
email: data.email as string,
}
}
})
if (ret.isSignUpComplete) {
// TODO: auto sign in
if (ret.nextStep?.signUpStep === 'COMPLETE_AUTO_SIGN_IN') {
const { nextStep } = await Auth.autoSignIn()
if (nextStep.signInStep === 'DONE') {
// signed in
setCurrentTab('login')
}
}
setCurrentTab('login')
return
} else {
if (ret.nextStep?.signUpStep === 'CONFIRM_SIGN_UP') {
setCurrentTab({
type: 'confirm-code',
props: {
user: { ...ret, username: data.username },
nextStep: 'CONFIRM_SIGN_UP'
}
})
}
return
}
} catch (e: any) {
console.error(e)
const error = { title: t('Bad Response.'), message: (e as Error).message }
let k = 'confirm_password'
if (e.name === 'UsernameExistsException') {
k = 'username'
}
setErrors({ [k]: error })
} finally {
setLoading(false)
}
}}>
<InputRow id="email" type="email" name="email" autoFocus={true} required={true} label={t('Email')}/>
<InputRow id="username" type="text" name="username" required={true} label={t('Username')}/>
<InputRow id="password" type="password" name="password"
required={true}
placeholder={t('Password')}
label={t('Password')}/>
<InputRow id="confirm_password" type="password" name="confirm_password"
required={true}
placeholder={t('Confirm Password')}
label={t('Confirm Password')}/>
<div className={'-mt-1'}>
<span className={'text-sm opacity-50'}>
{t('By signing up, you agree to our')}&nbsp;
<a href="https://logseq.com/terms"
target={'_blank'}
className={'underline hover:opacity-100'}>{t('Terms of Service')}</a>
{t(' and ')}
<a href="https://logseq.com/privacy-policy"
target={'_blank'}
className={'underline hover:opacity-100'}>{t('Privacy Policy')}</a>.
</span>
</div>
<div className={'w-full'}>
<Button type="submit" disabled={loading} className={'w-full'}>
{loading && <Loader2Icon className="animate-spin mr-1" size={16}/>}
{t('Create account')}
</Button>
</div>
<p className={'pt-1 text-center'}>
<a onClick={() => setCurrentTab('login')}
className={'text-sm opacity-60 hover:opacity-80 underline'}>
{t('Back to login')}
</a>
</p>
</FormGroup>
</>
)
}
export function ResetPasswordForm() {
const [isSentCode, setIsSentCode] = useState<boolean>(false)
const [sentUsername, setSentUsername] = useState<string>('')
const { setCurrentTab, setErrors } = useAuthFormState()
const { countDownNum, startCountDown } = useCountDown()
const [loading, setLoading] = useState<boolean>(false)
useEffect(() => {
setErrors({})
}, [isSentCode])
return (
<FormGroup
autoComplete={'off'}
onSubmit={async (e) => {
setErrors(null)
e.preventDefault()
// get submit form input data
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData.entries())
if (!isSentCode) {
try {
setLoading(true)
const username = (data.email as string)?.trim()
// send reset code
const ret = await Auth.resetPassword({ username })
console.debug('[Auth] reset pw code sent: ', ret)
setSentUsername(username)
startCountDown()
setIsSentCode(true)
} catch (error) {
console.error('Error sending reset code:', error)
setErrors({ email: { message: (error as Error).message, title: t('Bad Response.') } })
} finally {
setLoading(false)
}
} else {
// confirm reset password
if ((data.password as string)?.length < 8) {
setErrors({
password: {
message: t('Password must be at least 8 characters.'),
title: t('Invalid Password')
}
})
return
} else if (data.password !== data.confirm_password) {
setErrors({
confirm_password: {
message: t('Passwords do not match.'),
title: t('Invalid Password')
}
})
return
} else {
try {
setLoading(true)
const ret = await Auth.confirmResetPassword({
username: sentUsername,
newPassword: data.password as string,
confirmationCode: data.code as string
})
console.debug('[Auth] confirm reset pw: ', ret)
setCurrentTab('login')
} catch (error) {
console.error('Error confirming reset password:', error)
setErrors({ 'confirm_password': { message: (error as Error).message, title: t('Bad Response.') } })
} finally {
setLoading(false)
}
}
}
}}>
{isSentCode ? (
<>
<div className={'w-full opacity-60 flex justify-end relative h-0 z-[2]'}>
{countDownNum > 0 ? (
<span className={'text-sm opacity-50 select-none absolute top-3 right-0'}>
{countDownNum}s
</span>
) : (<a onClick={async () => {
startCountDown()
try {
const ret = await Auth.resetPassword({ username: sentUsername })
console.debug('[Auth] reset pw code re-sent: ', ret)
} catch (error) {
console.error('Error resending reset code:', error)
setErrors({ email: { message: (error as Error).message, title: t('Bad Response.') } })
} finally {}
}} className={'text-sm opacity-70 hover:opacity-90 underline absolute top-3 right-0 select-none'}>
{t('Resend code')}
</a>)}
</div>
<InputRow id="code" type="text" name="code" required={true}
placeholder={'123456'}
autoComplete={'off'}
label={t('Enter the code sent to your email')}/>
<InputRow id="password" type="password" name="password" required={true}
placeholder={t('New Password')}
label={t('New Password')}/>
<InputRow label={t('Confirm Password')}
id="confirm_password" type="password" name="confirm_password" required={true}
placeholder={t('Confirm Password')}/>
<div className={'w-full'}>
<Button type="submit"
className={'w-full'}
disabled={loading}
>
{loading && <Loader2Icon className="animate-spin mr-1" size={16}/>}
{t('Reset password')}
</Button>
<p className={'pt-4 text-center'}>
<a onClick={() => setCurrentTab('login')}
className={'text-sm opacity-60 hover:opacity-80 underline'}>
{t('Back to login')}
</a>
</p>
</div>
</>
) : (
<>
<InputRow id="email" type="email" name="email" required={true}
placeholder={'you@xx.com'}
autoFocus={true}
label={t('Enter your email')}/>
<div className={'w-full'}>
<Button type="submit"
className={'w-full'}
disabled={loading}
>
{loading && <Loader2Icon className="animate-spin mr-1" size={16}/>}
{t('Send code')}
</Button>
<p className={'pt-3 text-center'}>
<a onClick={() => setCurrentTab('login')}
className={'text-sm opacity-60 hover:opacity-80 underline'}>
{t('Back to login')}
</a>
</p>
</div>
</>
)}
</FormGroup>
)
}
export function ConfirmWithCodeForm(
props: { user: any, nextStep: any }
) {
const { setCurrentTab, setErrors } = useAuthFormState()
const [loading, setLoading] = useState<boolean>(false)
const isFromSignIn = props.user?.hasOwnProperty('isSignedIn')
const signUpCodeDeliveryDetails = props.user?.nextStep?.codeDeliveryDetails
const { countDownNum, startCountDown, setCountDownNum } = useCountDown()
return (
<FormGroup
autoComplete={'off'}
onSubmit={async (e) => {
setErrors(null)
e.preventDefault()
// get submit form input data
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData.entries())
try {
setLoading(true)
if (props.nextStep === 'CONFIRM_SIGN_UP') {
const ret = await Auth.confirmSignUp({
username: props.user?.username,
confirmationCode: data.code as string,
})
if (ret.nextStep?.signUpStep === 'COMPLETE_AUTO_SIGN_IN') {
const { nextStep } = await Auth.autoSignIn()
if (nextStep.signInStep === 'DONE') {
// signed in
setCurrentTab('login')
return
}
}
setCurrentTab('login')
} else {
const ret = await Auth.confirmSignIn({
challengeResponse: data.code as string,
})
console.debug('confirmSignIn: ', ret)
}
} catch (e) {
setErrors({ code: { message: (e as Error).message, title: t('Bad Response.') } })
console.error(e)
} finally {
setLoading(false)
}
}}>
<p className={'pb-2 opacity-60'}>
{isFromSignIn ? t('CODE_ON_THE_WAY_TIP') : (
signUpCodeDeliveryDetails &&
<span>{t('We have sent a numeric verification code to your email address at')}&nbsp;<code>
{signUpCodeDeliveryDetails.destination}.
</code></span>
)}
</p>
{/*<pre>*/}
{/* {JSON.stringify(props.user, null, 2)}*/}
{/* {JSON.stringify(props.nextStep, null, 2)}*/}
{/*</pre>*/}
<span className={'w-full flex justify-end relative h-0 z-10'}>
{countDownNum > 0 ? (
<span className={'text-sm opacity-50 select-none absolute -bottom-8'}>
{countDownNum}s
</span>
) : <a
className={'text-sm opacity-50 hover:opacity-80 active:opacity-50 select-none underline absolute -bottom-8'}
onClick={async (e) => {
e.stopPropagation()
// resend code
try {
startCountDown()
if (props.nextStep === 'CONFIRM_SIGN_UP') {
const ret = await Auth.resendSignUpCode({
username: props.user?.username
})
console.debug('resendSignUpCode: ', ret)
} else {
// await Auth.resendSignInCode(props.user)
}
} catch (e) {
setErrors({ code: { message: (e as Error).message, title: t('Bad Response.') } })
setCountDownNum(0)
console.error(e)
} finally {}
}}>{t('Resend code')}</a>
}
</span>
<InputRow id="code" type="text" name="code" required={true}
placeholder={'123456'}
autoComplete={'off'}
autoFocus={true}
label={t('Enter the code sent to your email')}/>
<div className={'w-full'}>
<Button type="submit"
className={'w-full'}
disabled={loading}
>
{loading && <Loader2Icon className="animate-spin mr-1" size={16}/>}
{t('Confirm')}
</Button>
<p className={'pt-4 text-center'}>
<a onClick={() => setCurrentTab('login')}
className={'text-sm opacity-60 hover:opacity-80 underline'}>
{t('Back to login')}
</a>
</p>
</div>
</FormGroup>
)
}
export function LSAuthenticator(props: any) {
const [errors, setErrors] = React.useState<string | null>(null)
const [currentTab, setCurrentTab] = React.useState<'login' | 'signup' | 'reset-password' | 'confirm-code' | any>('login')
const onSessionCallback = React.useCallback((session: any) => {
props.onSessionCallback?.(session)
}, [props.onSessionCallback])
React.useEffect(() => {
setErrors(null)
}, [currentTab])
let content = null
// support passing object with type field
let _currentTab = currentTab?.type ? currentTab.type : currentTab
let _currentTabProps = currentTab?.props || {}
switch (_currentTab) {
case 'login':
content = <LoginForm/>
break
case 'signup':
content = <SignupForm/>
break
case 'reset-password':
content = <ResetPasswordForm/>
break
case 'confirm-code':
content = <ConfirmWithCodeForm {..._currentTabProps}/>
break
}
return (
<AuthFormRootContext.Provider value={{
errors, setErrors, setCurrentTab,
onSessionCallback, userSessionRender: props.children
}}>
{props.titleRender?.(_currentTab, t(_currentTab))}
<div className={'ls-authenticator-content'}>
{content}
</div>
</AuthFormRootContext.Provider>
)
}

40
packages/ui/src/i18n.ts Normal file
View File

@@ -0,0 +1,40 @@
export type TranslateFn = (
locale: string,
dicts: Record<string, any>,
key: string,
...args: any
) => string
let _nsDicts = {}
let _locale: string = 'en'
let _translate: TranslateFn = (
locale: string,
dicts: Record<string, any>,
key: string,
...args: any
) => {
return dicts[locale]?.[key] || args[0] || key
}
export function setTranslate(t: TranslateFn) {
_translate = t
}
export function setLocale(locale: string) {
_locale = locale
}
export function setNSDicts(ns: string, dicts: Record<string, string>) {
(_nsDicts as any)[ns] = dicts
}
export const translate = (
ns: string,
key: string,
...args: any
) => {
const dicts = (_nsDicts as any)[ns] || {}
return _translate(
_nsDicts?.hasOwnProperty(_locale) ? _locale : 'en',
dicts, key, ...args)
}

View File

@@ -93,10 +93,14 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import * as uniqolor from 'uniqolor'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { setLocale, setTranslate } from './i18n'
import * as amplifyAuth from './amplify'
declare global {
var LSUI: any
var LSUtils: any
var LSI18N: any
var LSAuth: any
}
const shadui = {
@@ -199,6 +203,13 @@ function setupGlobals() {
isDev: process.env.NODE_ENV === 'development',
uniqolor,
}
window.LSI18N = {
setTranslate,
setLocale,
}
window.LSAuth = amplifyAuth
}
// setup

File diff suppressed because it is too large Load Diff