feat: improve email login

This commit is contained in:
0xzio
2025-05-16 23:29:43 +08:00
parent 4eb0488fdd
commit ef07fcd810
21 changed files with 429 additions and 89 deletions

View File

@@ -46,6 +46,7 @@ import { useEffect, useRef } from 'react'
import { appEmitter } from '@penx/emitter'
import { useCreationId } from '@penx/hooks/useCreationId'
import { ICreationNode } from '@penx/model-type'
import { NavProvider } from './components/NavContext'
import { PageCreation } from './pages/PageCreation'
async function init() {
@@ -120,7 +121,6 @@ setupIonicReact()
const App: React.FC = () => {
const nav = useRef<HTMLIonNavElement>(null)
const { creationId, setCreationId } = useCreationId()
useEffect(() => {
// if (initRef.current) return
@@ -153,7 +153,9 @@ const App: React.FC = () => {
<Redirect to="/folder/area" />
</Route>
<Route path="/folder/:name" exact={true}>
<IonNav ref={nav} root={() => <PageHome />}></IonNav>
<NavProvider nav={nav.current!}>
<IonNav ref={nav} root={() => <PageHome />}></IonNav>
</NavProvider>
</Route>
</IonRouterOutlet>
</IonSplitPane>

View File

@@ -1,42 +0,0 @@
'use client'
import React, { useMemo, useState } from 'react'
import { PageEmailLogin } from '@/pages/PageEmailLogin'
import { Capacitor } from '@capacitor/core'
import { SocialLogin } from '@capgo/capacitor-social-login'
import { IonButton, IonNavLink, useIonRouter } from '@ionic/react'
import { set } from 'idb-keyval'
import { MailIcon } from 'lucide-react'
import { appEmitter } from '@penx/emitter'
import { localDB } from '@penx/local-db'
import { queryClient } from '@penx/query-client'
import { useSession } from '@penx/session'
import { MobileGoogleLoginInfo } from '@penx/types'
import { Button } from '@penx/uikit/button'
import { IconGoogle } from '@penx/uikit/IconGoogle'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { LoginForm } from './LoginForm'
interface Props {}
export function EmailLoginButton({}: Props) {
const { login } = useSession()
const [json, setJson] = useState({})
const [error, setError] = useState({})
const [session, setSession] = useState({})
const [loading, setLoading] = useState(false)
const router = useIonRouter()
return (
<IonNavLink routerDirection="forward" component={() => <PageEmailLogin />}>
<Button className="w-full gap-2">
{loading && <LoadingDots className="bg-foreground" />}
{!loading && (
<>
<MailIcon size={20} />
<div className="">Email login</div>
</>
)}
</Button>
</IonNavLink>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import { createContext, PropsWithChildren, useContext } from 'react'
export const NavContext = createContext({} as HTMLIonNavElement)
interface Props {
nav: HTMLIonNavElement
}
export const NavProvider = ({ nav, children }: PropsWithChildren<Props>) => {
return <NavContext.Provider value={nav}>{children}</NavContext.Provider>
}
export function useNavContext() {
const nav = useContext(NavContext)
return nav
}

View File

@@ -0,0 +1,19 @@
'use client'
import React from 'react'
import { PageEmailLogin } from '@/pages/PageEmailLogin'
import { IonNavLink } from '@ionic/react'
import { MailIcon } from 'lucide-react'
import { Button } from '@penx/uikit/button'
interface Props {}
export function EmailLoginButton({}: Props) {
return (
<IonNavLink routerDirection="forward" component={() => <PageEmailLogin />}>
<Button className="w-full gap-2">
<MailIcon size={20} />
<div className="">Email login</div>
</Button>
</IonNavLink>
)
}

View File

@@ -24,7 +24,10 @@ const platform = Capacitor.getPlatform()
export function LoginContent({}: Props) {
return (
<div className="flex h-full flex-1 flex-col justify-center px-6">
<div className="space-y-2">
<div className="-mt-20 space-y-2">
<div className="mb-10 text-center text-2xl font-bold">
Welcome to PenX
</div>
<GoogleLoginButton />
{platform === 'ios' && <AppleLoginButton />}

View File

@@ -8,7 +8,7 @@ import { set } from 'idb-keyval'
import { toast } from 'sonner'
import { z } from 'zod'
import { appEmitter } from '@penx/emitter'
import { useRouter } from '@penx/libs/i18n'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { queryClient } from '@penx/query-client'
import { useSession } from '@penx/session'
import { Button } from '@penx/uikit/button'
@@ -22,11 +22,10 @@ import {
import { Input } from '@penx/uikit/input'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
import { useNavContext } from '../NavContext'
const FormSchema = z.object({
name: z.string().min(4, {
message: 'Username must be at least 4 characters.',
}),
name: z.string().email(),
password: z.string().min(4, {
message: 'Password must be at least 4 characters.',
}),
@@ -37,6 +36,8 @@ interface Props {}
export function LoginForm({}: Props) {
const [isLoading, setLoading] = useState(false)
const { login } = useSession()
const { setAuthStatus } = useAuthStatus()
const nav = useNavContext()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
@@ -47,9 +48,9 @@ export function LoginForm({}: Props) {
})
async function onSubmit(data: z.infer<typeof FormSchema>) {
console.log('=====>>>>>>>nav:', nav);
try {
console.log('======data:', data)
setLoading(true)
const session = await login({
@@ -65,6 +66,7 @@ export function LoginForm({}: Props) {
await set('SESSION', session)
queryClient.setQueryData(['SESSION'], session)
appEmitter.emit('APP_LOGIN_SUCCESS', session)
nav.pop()
}
} catch (error) {
console.log('========error:', error)
@@ -102,6 +104,7 @@ export function LoginForm({}: Props) {
<FormItem className="w-full">
<FormControl>
<Input
autoComplete="current-password"
type="password"
placeholder="Password"
{...field}
@@ -124,6 +127,16 @@ export function LoginForm({}: Props) {
</Button>
</div>
</form>
<div className="pt-4 text-center text-sm">
<Trans id="No account"></Trans>?{' '}
<span
className="text-brand"
onClick={() => setAuthStatus({ type: 'register' })}
>
<Trans id="Create one"></Trans>
</span>
</div>
</Form>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Trans } from '@lingui/react'
import { set } from 'idb-keyval'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { z } from 'zod'
import { appEmitter } from '@penx/emitter'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { localDB } from '@penx/local-db'
import { queryClient } from '@penx/query-client'
import { useSession } from '@penx/session'
import { api } from '@penx/trpc-client'
import { Button } from '@penx/uikit/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@penx/uikit/form'
import { Input } from '@penx/uikit/input'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
import { useNavContext } from '../NavContext'
const FormSchema = z.object({
code: z.string(),
})
interface Props {}
export function PinCodeForm({}: Props) {
const { login } = useSession()
const [isLoading, setLoading] = useState(false)
const { authStatus } = useAuthStatus()
const nav = useNavContext()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
code: '',
},
})
async function onSubmit(data: z.infer<typeof FormSchema>) {
try {
setLoading(true)
const session = await login({
type: 'register-by-code',
code: data.code,
})
console.log('=====session:', session)
if (!session.isLoggedIn) {
toast.error(session.message)
} else {
await set('SESSION', session)
queryClient.setQueryData(['SESSION'], session)
appEmitter.emit('APP_LOGIN_SUCCESS', session)
nav.pop()
}
} catch (error) {
console.log('========error:', error)
const msg = extractErrorMessage(error)
toast.error(msg)
}
setLoading(false)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-foreground/60">
<Trans id="Please check your email for the verification code."></Trans>
</p>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel className="flex items-center justify-between">
<span>
<Trans id="Login code"></Trans>
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={async () => {
try {
await api.auth.registerByEmail.mutate(
authStatus.data as any,
)
toast.success('Verification code sent successfully')
} catch (error) {
console.log('========error:', error)
const msg = extractErrorMessage(error)
toast.error(msg)
}
}}
>
Resend
</Button>
</FormLabel>
<FormControl>
<Input placeholder="" {...field} className="w-full" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
size="lg"
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? <LoadingDots /> : <Trans id="Register"></Trans>}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import React, { useMemo, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { SocialLogin } from '@capgo/capacitor-social-login'
import { LogOutIcon } from 'lucide-react'
import { useSession } from '@penx/session'
import { Avatar, AvatarFallback, AvatarImage } from '@penx/uikit/avatar'
import { cn, getUrl } from '@penx/utils'
import { generateGradient } from '@penx/utils/generateGradient'
interface Props {}
const platform = Capacitor.getPlatform()
export function ProfileContent({}: Props) {
const { session, logout } = useSession()
return (
<div className="flex h-full flex-1 flex-col gap-2 px-3 pt-4">
<div className="flex items-center gap-2 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={getUrl(session?.image)} alt={session?.name} />
<AvatarFallback
className={cn(
'rounded-lg text-white',
generateGradient(session.name),
)}
>
{session?.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{session?.name}</span>
<span className="truncate text-xs">{session?.email}</span>
</div>
</div>
<div
className="bg-foreground/5 flex items-center justify-between rounded-lg px-3 py-2"
onClick={() => {
logout()
}}
>
<div>Logout</div>
<LogOutIcon size={20} className="text-foreground/60"></LogOutIcon>
</div>
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Trans } from '@lingui/react'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { z } from 'zod'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { localDB } from '@penx/local-db'
import { api } from '@penx/trpc-client'
import { Button } from '@penx/uikit/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@penx/uikit/form'
import { Input } from '@penx/uikit/input'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
const FormSchema = z.object({
email: z.string().email(),
password: z.string().min(6, {
message: 'Password must be at least 6 characters.',
}),
})
interface Props {}
export function RegisterForm({}: Props) {
const [isLoading, setLoading] = useState(false)
const { setAuthStatus } = useAuthStatus()
const searchParams = useSearchParams()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: '',
password: '',
},
})
async function onSubmit(data: z.infer<typeof FormSchema>) {
try {
const sites = await localDB.listAllSites()
const site = sites.find((s) => !s.props.isRemote)
const ref = searchParams?.get('ref') as string
setLoading(true)
await api.auth.registerByEmail.mutate({
...data,
ref: ref || '',
userId: site?.userId,
})
setAuthStatus({
type: 'register-email-sent',
data: {
...data,
ref: ref || '',
userId: site?.userId,
},
})
} catch (error) {
console.log('========error:', error)
const msg = extractErrorMessage(error)
toast.error(msg)
}
setLoading(false)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>
<Trans id="Email"></Trans>
</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} className="w-full" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>
<Trans id="Password"></Trans>
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
size="lg"
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? <LoadingDots /> : <Trans id="Register"></Trans>}
</Button>
</div>
</form>
<div className="mt-2 text-center text-sm">
<Trans id="Already have an account"></Trans>?{' '}
<span
className="text-brand"
onClick={(e) => {
e.preventDefault()
setAuthStatus({ type: 'login' })
}}
>
<Trans id="Log in"></Trans>
</span>
</div>
</Form>
)
}

View File

@@ -1,4 +1,7 @@
import React from 'react'
import { LoginForm } from '@/components/Profile/LoginForm'
import { PinCodeForm } from '@/components/Profile/PinCodeForm'
import { RegisterForm } from '@/components/Profile/RegisterForm'
import { Capacitor } from '@capacitor/core'
import {
IonBackButton,
@@ -10,42 +13,45 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
const platform = Capacitor.getPlatform()
export function PageEmailLogin() {
const { authStatus } = useAuthStatus()
return (
<>
<IonHeader
className={platform === 'android' ? 'safe-area' : ''}
style={
{
boxShadow: '0 0 0 rgba(0, 0, 0, 0)',
}
}
style={{
boxShadow: '0 0 0 rgba(0, 0, 0, 0)',
}}
>
<IonToolbar
className="toolbar"
style={
{
'--border-width': 0,
// borderBottom: scrolled ? '1px solid #eeee' : 'none',
// borderBottom: 'none',
// border: 'none',
}
}
style={{
'--border-width': 0,
// borderBottom: scrolled ? '1px solid #eeee' : 'none',
// borderBottom: 'none',
// border: 'none',
}}
>
<IonButtons slot="start">
<IonBackButton></IonBackButton>
<IonBackButton color="dark" text=""></IonBackButton>
</IonButtons>
<IonTitle>Page Two</IonTitle>
<IonTitle>Email login</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<h1>Page Two</h1>
{/* <IonNavLink routerDirection="forward" component={() => <PageThree />}>
<IonButton>Go to Page Three</IonButton>
</IonNavLink> */}
<div className="flex h-full w-full flex-col justify-center">
{authStatus.type === 'login' && <LoginForm></LoginForm>}
{authStatus.type === 'register' && <RegisterForm />}
{authStatus.type === 'register-email-sent' && (
<PinCodeForm></PinCodeForm>
)}
</div>
</IonContent>
</>
)

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'
import { Footer } from '@/components/Footer'
import { LoginContent } from '@/components/Login/LoginContent'
import { MobileHome } from '@/components/MobileHome'
import { SearchButton } from '@/components/MobileSearch/SearchButton'
import { MobileTask } from '@/components/MobileTask/MobileTask'
import { LoginContent } from '@/components/Profile/LoginContent'
import { ProfileContent } from '@/components/Profile/ProfileContent'
import { useHomeTab } from '@/hooks/useHomeTab'
import { Capacitor } from '@capacitor/core'
import { SplashScreen } from '@capacitor/splash-screen'
@@ -40,6 +41,7 @@ import { appEmitter } from '@penx/emitter'
import { useArea } from '@penx/hooks/useArea'
import { useCreationId } from '@penx/hooks/useCreationId'
import { ICreationNode } from '@penx/model-type'
import { useSession } from '@penx/session'
import { Button } from '@penx/uikit/button'
import { Separator } from '@penx/uikit/separator'
import { cn } from '@penx/utils'
@@ -53,6 +55,7 @@ const PageHome: React.FC = ({ nav }: any) => {
const { area } = useArea()
const y = useMotionValue(0)
const { isHome, type, setType } = useHomeTab()
const { session } = useSession()
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -207,7 +210,8 @@ const PageHome: React.FC = ({ nav }: any) => {
>
{type === 'HOME' && <MobileHome />}
{type === 'TASK' && <MobileTask />}
{type === 'PROFILE' && <LoginContent />}
{type === 'PROFILE' &&
(session ? <ProfileContent /> : <LoginContent />)}
</div>
</IonContent>

View File

@@ -35,6 +35,7 @@
"@penx/local-db": "workspace:*",
"@penx/model-type": "workspace:*",
"@penx/domain": "workspace:*",
"@penx/hooks": "workspace:*",
"@penx/query-client": "workspace:*",
"@penx/editor-transforms": "workspace:*",
"@penx/contexts": "workspace:*",

View File

@@ -1,6 +1,7 @@
'use client'
import { Trans } from '@lingui/react'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import {
Dialog,
DialogContent,
@@ -11,7 +12,6 @@ import {
import { LoginDialogContent } from './LoginDialogContent'
import { PinCodeForm } from './PinCodeForm'
import { RegisterForm } from './RegisterForm'
import { useAuthStatus } from './useAuthStatus'
import { useLoginDialog } from './useLoginDialog'
interface Props {}

View File

@@ -1,14 +1,5 @@
'use client'
import { useCallback } from 'react'
import {
AuthKitProvider,
SignInButton as FSignInButton,
QRCode,
StatusAPIResponse,
useProfile,
useSignIn,
} from '@farcaster/auth-kit'
import { Trans } from '@lingui/react'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
@@ -19,10 +10,6 @@ import { LoginForm } from './LoginForm'
import { useLoginDialog } from './useLoginDialog'
export function LoginDialogContent() {
const { setIsOpen } = useLoginDialog()
const { login, logout } = useSession()
const searchParams = useSearchParams()
return (
<div className="flex flex-col gap-3 pb-5">
<div className="space-y-1">

View File

@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { Trans } from '@lingui/react'
import { toast } from 'sonner'
import { z } from 'zod'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { useRouter } from '@penx/libs/i18n'
import { useSession } from '@penx/session'
import { Button } from '@penx/uikit/button'
@@ -19,7 +20,6 @@ import {
import { Input } from '@penx/uikit/input'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
import { useAuthStatus } from './useAuthStatus'
import { useLoginDialog } from './useLoginDialog'
const FormSchema = z.object({

View File

@@ -7,6 +7,7 @@ import { Trans } from '@lingui/react'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { z } from 'zod'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { localDB } from '@penx/local-db'
import { useSession } from '@penx/session'
import { api } from '@penx/trpc-client'
@@ -28,7 +29,6 @@ import {
InputOTPSlot,
} from '@penx/uikit/ui/input-otp'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
import { useAuthStatus } from './useAuthStatus'
import { useLoginDialog } from './useLoginDialog'
const FormSchema = z.object({
@@ -91,6 +91,7 @@ export function PinCodeForm({}: Props) {
<Trans id="Login code"></Trans>
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={async () => {

View File

@@ -7,6 +7,7 @@ import { Trans } from '@lingui/react'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { z } from 'zod'
import { useAuthStatus } from '@penx/hooks/useAuthStatus'
import { localDB } from '@penx/local-db'
import { api } from '@penx/trpc-client'
import { Button } from '@penx/uikit/button'
@@ -21,7 +22,6 @@ import {
import { Input } from '@penx/uikit/input'
import { LoadingDots } from '@penx/uikit/loading-dots'
import { extractErrorMessage } from '@penx/utils/extractErrorMessage'
import { useAuthStatus } from './useAuthStatus'
const FormSchema = z.object({
email: z.string().email(),

3
pnpm-lock.yaml generated
View File

@@ -2451,6 +2451,9 @@ importers:
'@penx/editor-transforms':
specifier: workspace:*
version: link:../editor-transforms
'@penx/hooks':
specifier: workspace:*
version: link:../hooks
'@penx/libs':
specifier: workspace:*
version: link:../libs