mirror of
https://github.com/penxio/penx.git
synced 2026-04-19 03:03:06 -04:00
feat: improve login
This commit is contained in:
@@ -4,14 +4,13 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"plugins": [
|
||||
"@ianvs/prettier-plugin-sort-imports"
|
||||
],
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": [
|
||||
"^react",
|
||||
"<BUILTIN_MODULES>",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^~(.*)$",
|
||||
"^@(.*)$",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function GET(req: NextRequest) {
|
||||
email,
|
||||
name: name || '',
|
||||
displayName: name,
|
||||
image: picture,
|
||||
google: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
import { NETWORK } from '@/lib/constants'
|
||||
import { getBasePublicClient } from '@/lib/getBasePublicClient'
|
||||
import {
|
||||
createUserByAddress,
|
||||
createUserByGoogleInfo,
|
||||
getSessionOptions,
|
||||
isGoogleLogin,
|
||||
isWalletLogin,
|
||||
SessionData,
|
||||
} from '@/lib/session'
|
||||
import { getAccountAddress } from '@/lib/utils'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest } from 'next/server'
|
||||
@@ -17,6 +6,20 @@ import {
|
||||
validateSiweMessage,
|
||||
type SiweMessage,
|
||||
} from 'viem/siwe'
|
||||
import { NETWORK } from '@/lib/constants'
|
||||
import { getBasePublicClient } from '@/lib/getBasePublicClient'
|
||||
import {
|
||||
createUserByAddress,
|
||||
createUserByGoogleInfo,
|
||||
getServerSession,
|
||||
getSessionOptions,
|
||||
isGoogleLogin,
|
||||
isPasswordLogin,
|
||||
isWalletLogin,
|
||||
loginByPassword,
|
||||
SessionData,
|
||||
} from '@/lib/session'
|
||||
import { getAccountAddress } from '@/lib/utils'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
@@ -30,12 +33,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (isGoogleLogin(json)) {
|
||||
const account = (await createUserByGoogleInfo(json))!
|
||||
|
||||
session.isLoggedIn = true
|
||||
session.userId = account.userId
|
||||
session.address = getAccountAddress(account)
|
||||
session.name = account.user.name as string
|
||||
session.image = account.user.image as string
|
||||
session.role = account.user.role as string
|
||||
session.message = ''
|
||||
session.subscriptions = Array.isArray(account.user.subscriptions)
|
||||
? account.user.subscriptions.map((i: any) => ({
|
||||
planId: i.planId,
|
||||
@@ -81,6 +86,7 @@ export async function POST(request: NextRequest) {
|
||||
const account = (await createUserByAddress(address.toLowerCase()))!
|
||||
|
||||
session.isLoggedIn = true
|
||||
session.message = ''
|
||||
session.userId = account.userId
|
||||
session.address = address
|
||||
session.name = account.user.name as string
|
||||
@@ -101,7 +107,42 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('======json============', json)
|
||||
if (isPasswordLogin(json)) {
|
||||
try {
|
||||
const account = (await loginByPassword(json.username, json.password))!
|
||||
session.isLoggedIn = true
|
||||
session.message = ''
|
||||
session.userId = account.userId
|
||||
session.address = getAccountAddress(account)
|
||||
session.name = account.user.name as string
|
||||
session.image = account.user.image as string
|
||||
session.role = account.user.role as string
|
||||
session.subscriptions = Array.isArray(account.user.subscriptions)
|
||||
? account.user.subscriptions.map((i: any) => ({
|
||||
planId: i.planId,
|
||||
startTime: i.startTime,
|
||||
duration: i.duration,
|
||||
}))
|
||||
: []
|
||||
|
||||
await session.save()
|
||||
return Response.json(session)
|
||||
} catch (error: any) {
|
||||
console.log('error.mess==:', error.message)
|
||||
|
||||
if (error.message === 'INVALID_USERNAME') {
|
||||
session.message = 'Invalid username'
|
||||
}
|
||||
|
||||
if (error.message === 'INVALID_PASSWORD') {
|
||||
session.message = 'Invalid password'
|
||||
}
|
||||
|
||||
session.isLoggedIn = false
|
||||
await session.save()
|
||||
return Response.json(session)
|
||||
}
|
||||
}
|
||||
|
||||
session.isLoggedIn = false
|
||||
await session.save()
|
||||
@@ -130,11 +171,7 @@ export async function PATCH() {
|
||||
|
||||
// read session
|
||||
export async function GET() {
|
||||
const sessionOptions = getSessionOptions()
|
||||
const session = await getIronSession<SessionData>(
|
||||
await cookies(),
|
||||
sessionOptions,
|
||||
)
|
||||
const session = await getServerSession()
|
||||
|
||||
if (session?.isLoggedIn !== true) {
|
||||
return Response.json({})
|
||||
@@ -150,6 +187,7 @@ export async function DELETE() {
|
||||
await cookies(),
|
||||
sessionOptions,
|
||||
)
|
||||
|
||||
session.destroy()
|
||||
return Response.json({ isLoggedIn: false })
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default async function Page() {
|
||||
<Card className="flex flex-col sm:w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Login with Google or Web3 Wallets</CardDescription>
|
||||
<CardDescription>Login to write</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { getSite } from '@/lib/fetchers'
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getSite } from '@/lib/fetchers'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
// if (process.env.NODE_ENV === 'development') return {}
|
||||
const site = await getSite()
|
||||
return {
|
||||
name: site.name || 'PenX',
|
||||
short_name: site.name || 'PenX',
|
||||
description: site.description || '',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
icons: [
|
||||
{
|
||||
src: '/images/logo-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/images/logo-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
try {
|
||||
const site = await getSite()
|
||||
return {
|
||||
name: site?.name || 'PenX',
|
||||
short_name: site?.name || 'PenX',
|
||||
description: site?.description || '',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
icons: [
|
||||
{
|
||||
src: '/images/logo-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/images/logo-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import { createSiweMessage } from 'viem/siwe'
|
||||
import { WagmiProvider } from 'wagmi'
|
||||
import { GoogleOauthDialog } from '@/components/GoogleOauthDialog/GoogleOauthDialog'
|
||||
import { LoginDialog } from '@/components/LoginDialog/LoginDialog'
|
||||
import { SiteProvider } from '@/components/SiteContext'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { StoreProvider } from '@/lib/store'
|
||||
@@ -15,18 +19,14 @@ import {
|
||||
} from '@rainbow-me/rainbowkit'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { signMessage } from '@wagmi/core'
|
||||
import { Toaster } from 'sonner'
|
||||
import { createSiweMessage } from 'viem/siwe'
|
||||
import { WagmiProvider } from 'wagmi'
|
||||
import { RainbowKitSiweProvider } from './RainbowKitSiweProvider'
|
||||
|
||||
function RainbowProvider({ children }: { children: React.ReactNode }) {
|
||||
const { status } = useSession()
|
||||
|
||||
return (
|
||||
<RainbowKitSiweProvider>
|
||||
<RainbowKitProvider>
|
||||
<StoreProvider>
|
||||
<LoginDialog />
|
||||
<GoogleOauthDialog />
|
||||
{children}
|
||||
</StoreProvider>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { KeyIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconGoogle } from '@/components/icons/IconGoogle'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { extractErrorMessage } from '@/lib/extractErrorMessage'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { ProviderType } from '@/lib/types'
|
||||
import { shortenAddress } from '@/lib/utils'
|
||||
import { Account } from '@/server/db/schema'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function AccountItem({ account }: { account: Account }) {
|
||||
const { refetch } = useMyAccounts()
|
||||
@@ -76,6 +77,27 @@ function AccountItem({ account }: { account: Account }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (account.providerType === ProviderType.PASSWORD) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<KeyIcon size={14} />
|
||||
<span>Password</span>
|
||||
</Badge>
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage src={info?.picture} />
|
||||
<AvatarFallback>
|
||||
{account.providerAccountId?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="">{account.providerAccountId}/---</div>
|
||||
</div>
|
||||
{removeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { PasswordDialog } from '@/components/PasswordDialog/PasswordDialog'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { ProviderType } from '@/lib/types'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { AccountList } from './AccountList'
|
||||
import { LinkGoogleButton } from './LinkGoogleButton'
|
||||
import { LinkPasswordButton } from './LinkPasswordButton'
|
||||
import { LinkWalletButton } from './LinkWalletButton'
|
||||
|
||||
export function LinkAccounts() {
|
||||
@@ -30,8 +32,13 @@ export function LinkAccounts() {
|
||||
|
||||
const hasWallet = accounts.some((a) => a.providerType === ProviderType.WALLET)
|
||||
|
||||
const hasPassword = accounts.some(
|
||||
(a) => a.providerType === ProviderType.PASSWORD,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<PasswordDialog />
|
||||
{isLoading && <LoadingDots className="bg-foreground/60" />}
|
||||
{!isLoading && (
|
||||
<div className="grid gap-6 w-full md:w-[400px]">
|
||||
@@ -39,6 +46,7 @@ export function LinkAccounts() {
|
||||
<div className="space-y-2">
|
||||
{!hasGoogleAccount && <LinkGoogleButton />}
|
||||
{!hasWallet && <LinkWalletButton />}
|
||||
{!hasPassword && <LinkPasswordButton />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { IconGoogle } from '@/components/icons/IconGoogle'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
} from '@/lib/constants'
|
||||
import useSession from '@/lib/useSession'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function LinkGoogleButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -29,6 +29,7 @@ export function LinkGoogleButton() {
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn('rounded-lg gap-2 w-full')}
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { KeyIcon } from 'lucide-react'
|
||||
import { usePasswordDialog } from '@/components/PasswordDialog/usePasswordDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LinkPasswordButton() {
|
||||
const { setIsOpen } = usePasswordDialog()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn('rounded-lg gap-2 w-full')}
|
||||
onClick={async () => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<KeyIcon size={16} />
|
||||
<div className="">Set password</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { extractErrorMessage } from '@/lib/extractErrorMessage'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { api } from '@/lib/trpc'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
import { toast } from 'sonner'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
|
||||
export function LinkWalletButton() {
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
@@ -20,6 +20,7 @@ export function LinkWalletButton() {
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn('rounded-lg gap-2 w-full')}
|
||||
disabled={isLoading}
|
||||
onClick={async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { IconGoogle } from '@/components/icons/IconGoogle'
|
||||
import LoadingCircle from '@/components/icons/loading-circle'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { getGoogleUserInfo } from '@/lib/getGoogleUserInfo'
|
||||
import useSession from '@/lib/useSession'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { ClientOnly } from '../ClientOnly'
|
||||
import { useGoogleOauthDialog } from './useGoogleOauthDialog'
|
||||
|
||||
@@ -43,7 +43,7 @@ export function GoogleOauthDialog() {
|
||||
}
|
||||
location.href = `${location.origin}/${location.pathname}`
|
||||
},
|
||||
[searchParams],
|
||||
[searchParams, login],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,6 +59,7 @@ export function GoogleOauthDialog() {
|
||||
closable={false}
|
||||
className="h-64 flex items-center justify-center w-[96%] sm:max-w-[425px] rounded-xl focus-visible:outline-none"
|
||||
>
|
||||
<DialogTitle className="hidden"></DialogTitle>
|
||||
<IconGoogle className="w-6 h-6" />
|
||||
<div className="text-lg">Logging in</div>
|
||||
<LoadingDots className="bg-foreground/60" />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLoginDialog } from './LoginDialog/useLoginDialog'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export default function LoginButton() {
|
||||
const { push } = useRouter()
|
||||
const { setIsOpen } = useLoginDialog()
|
||||
return (
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/login">Sign in</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
59
components/LoginDialog/LoginDialog.tsx
Normal file
59
components/LoginDialog/LoginDialog.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { GoogleOauthButton } from '../GoogleOauthButton'
|
||||
import { WalletConnectButton } from '../WalletConnectButton'
|
||||
import { LoginForm } from './LoginForm'
|
||||
import { useLoginDialog } from './useLoginDialog'
|
||||
|
||||
interface Props {}
|
||||
|
||||
export function LoginDialog({}: Props) {
|
||||
const { isOpen, setIsOpen } = useLoginDialog()
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
|
||||
<DialogContent className="sm:max-w-[425px] grid gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="">Login</DialogTitle>
|
||||
<DialogDescription>Login to write post</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* <div className="text-foreground/40">Web2 login</div> */}
|
||||
<GoogleOauthButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* <div className="text-foreground/40">Wallet login</div> */}
|
||||
<WalletConnectButton
|
||||
size="lg"
|
||||
className="w-full border-foreground"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="i-[token--ethm] w-6 h-5"></span>
|
||||
<span>Wallet login </span>
|
||||
</WalletConnectButton>
|
||||
</div>
|
||||
<div className="text-center text-foreground/40">or</div>
|
||||
<LoginForm />
|
||||
|
||||
{/* <Separator /> */}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
112
components/LoginDialog/LoginForm.tsx
Normal file
112
components/LoginDialog/LoginForm.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { extractErrorMessage } from '@/lib/extractErrorMessage'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import useSession from '@/lib/useSession'
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(4, {
|
||||
message: 'Username must be at least 4 characters.',
|
||||
}),
|
||||
password: z.string().min(4, {
|
||||
message: 'Password must be at least 4 characters.',
|
||||
}),
|
||||
})
|
||||
|
||||
interface Props {}
|
||||
|
||||
export function LoginForm({}: Props) {
|
||||
const { login } = useSession()
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await login({
|
||||
type: 'password',
|
||||
username: data.name,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
if (result.isLoggedIn) {
|
||||
toast.success('Login successfully!')
|
||||
location.reload()
|
||||
} else {
|
||||
toast.error(result.message || 'Login failed!')
|
||||
}
|
||||
} 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="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input placeholder="Username" {...field} className="w-full" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input placeholder="Password" {...field} className="w-full" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <LoadingDots /> : <p>Login</p>}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
10
components/LoginDialog/useLoginDialog.ts
Normal file
10
components/LoginDialog/useLoginDialog.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { atom, useAtom } from 'jotai'
|
||||
|
||||
const loginDialogAtom = atom<boolean>(false)
|
||||
|
||||
export function useLoginDialog() {
|
||||
const [isOpen, setIsOpen] = useAtom(loginDialogAtom)
|
||||
return { isOpen, setIsOpen }
|
||||
}
|
||||
30
components/PasswordDialog/PasswordDialog.tsx
Normal file
30
components/PasswordDialog/PasswordDialog.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PasswordForm } from './PasswordForm'
|
||||
import { usePasswordDialog } from './usePasswordDialog'
|
||||
|
||||
interface Props {}
|
||||
|
||||
export function PasswordDialog({}: Props) {
|
||||
const { isOpen, setIsOpen } = usePasswordDialog()
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
|
||||
<DialogContent className="sm:max-w-[425px] grid gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="">Username and Password</DialogTitle>
|
||||
<DialogDescription>Set username and password</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<PasswordForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
107
components/PasswordDialog/PasswordForm.tsx
Normal file
107
components/PasswordDialog/PasswordForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { LoadingDots } from '@/components/icons/loading-dots'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { extractErrorMessage } from '@/lib/extractErrorMessage'
|
||||
import { useMyAccounts } from '@/lib/hooks/useMyAccounts'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import useSession from '@/lib/useSession'
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { usePasswordDialog } from './usePasswordDialog'
|
||||
|
||||
const FormSchema = z.object({
|
||||
username: z.string().min(4, {
|
||||
message: 'Username must be at least 4 characters.',
|
||||
}),
|
||||
password: z.string().min(4, {
|
||||
message: 'Password must be at least 4 characters.',
|
||||
}),
|
||||
})
|
||||
|
||||
interface Props {}
|
||||
|
||||
export function PasswordForm({}: Props) {
|
||||
const { setIsOpen } = usePasswordDialog()
|
||||
const { refetch } = useMyAccounts()
|
||||
const { isPending, mutateAsync } = trpc.user.linkPassword.useMutation()
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
try {
|
||||
await mutateAsync({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
})
|
||||
await refetch()
|
||||
setIsOpen(false)
|
||||
toast.success('Set password successfully')
|
||||
} catch (error) {
|
||||
console.log('========error:', error)
|
||||
const msg = extractErrorMessage(error)
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input placeholder="Username" {...field} className="w-full" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input placeholder="Password" {...field} className="w-full" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <LoadingDots /> : <p>Confirm</p>}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
10
components/PasswordDialog/usePasswordDialog.ts
Normal file
10
components/PasswordDialog/usePasswordDialog.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { atom, useAtom } from 'jotai'
|
||||
|
||||
const passwordDialogAtom = atom<boolean>(false)
|
||||
|
||||
export function usePasswordDialog() {
|
||||
const [isOpen, setIsOpen] = useAtom(passwordDialogAtom)
|
||||
return { isOpen, setIsOpen }
|
||||
}
|
||||
@@ -8,16 +8,15 @@ import { Avatar, AvatarFallback } from './ui/avatar'
|
||||
|
||||
interface Props extends ButtonProps {}
|
||||
|
||||
export const WalletConnectButton = (props: Props) => {
|
||||
export const WalletConnectButton = ({ onClick, ...props }: Props) => {
|
||||
const { openConnectModal } = useConnectModal()
|
||||
const address = useAddress()
|
||||
|
||||
async function onOpen() {
|
||||
async function onOpen(e: any) {
|
||||
openConnectModal?.()
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
onOpen()
|
||||
setTimeout(() => {
|
||||
onClick?.(e)
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// if (address) {
|
||||
@@ -29,7 +28,7 @@ export const WalletConnectButton = (props: Props) => {
|
||||
// }
|
||||
|
||||
return (
|
||||
<Button onClick={onClick} {...props}>
|
||||
<Button onClick={onOpen} {...props}>
|
||||
{props.children ? props.children : 'Connect'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react'
|
||||
import { useSiteContext } from '@/components/SiteContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { WalletConnectButton } from '@/components/WalletConnectButton'
|
||||
import { AuthType } from '@/lib/types'
|
||||
import useSession from '@/lib/useSession'
|
||||
import { Post } from '@penxio/types'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { compareSync } from 'bcrypt-edge'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getIronSession, SessionOptions } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
import { db } from '@/server/db'
|
||||
import { accounts, posts, sites, users } from '@/server/db/schema'
|
||||
import { getRequestContext } from '@cloudflare/next-on-pages'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getIronSession, SessionOptions } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
import { defaultPostContent } from './constants'
|
||||
import { PostStatus, PostType, ProviderType, UserRole } from './types'
|
||||
|
||||
@@ -22,6 +23,7 @@ export interface SessionData {
|
||||
ensName: string | null
|
||||
role: string
|
||||
subscriptions: SubscriptionInSession[]
|
||||
message: string
|
||||
}
|
||||
|
||||
export type GoogleLoginInfo = {
|
||||
@@ -31,6 +33,8 @@ export type GoogleLoginInfo = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type LoginData = GoogleLoginData | WalletLoginData | PasswordLoginData
|
||||
|
||||
export type GoogleLoginData = GoogleLoginInfo & {
|
||||
type: 'penx-google'
|
||||
}
|
||||
@@ -41,6 +45,12 @@ export type WalletLoginData = {
|
||||
signature: string
|
||||
}
|
||||
|
||||
export type PasswordLoginData = {
|
||||
type: 'password'
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function isGoogleLogin(value: any): value is GoogleLoginData {
|
||||
return typeof value === 'object' && value?.type === 'penx-google'
|
||||
}
|
||||
@@ -49,6 +59,10 @@ export function isWalletLogin(value: any): value is WalletLoginData {
|
||||
return typeof value === 'object' && value?.type === 'wallet'
|
||||
}
|
||||
|
||||
export function isPasswordLogin(value: any): value is PasswordLoginData {
|
||||
return typeof value === 'object' && value?.type === 'password'
|
||||
}
|
||||
|
||||
export function getSessionOptions() {
|
||||
const { env } = getRequestContext()
|
||||
const sessionOptions: SessionOptions = {
|
||||
@@ -70,6 +84,7 @@ export async function getServerSession() {
|
||||
await cookies(),
|
||||
sessionOptions,
|
||||
)) as SessionData
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -150,6 +165,24 @@ export async function createUserByAddress(address: any) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function loginByPassword(username: string, password: string) {
|
||||
const account = await db.query.accounts.findFirst({
|
||||
where: and(
|
||||
eq(accounts.providerType, ProviderType.PASSWORD),
|
||||
eq(accounts.providerAccountId, username),
|
||||
),
|
||||
with: { user: true },
|
||||
})
|
||||
|
||||
if (!account) {
|
||||
throw new Error('INVALID_USERNAME')
|
||||
}
|
||||
|
||||
const match = compareSync(password, account.accessToken || '')
|
||||
if (!match) throw new Error('INVALID_PASSWORD')
|
||||
return account
|
||||
}
|
||||
|
||||
async function initSite(userId: string) {
|
||||
await Promise.all([
|
||||
db.insert(sites).values({
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
export enum AuthType {
|
||||
GOOGLE = 'GOOGLE',
|
||||
REOWN = 'REOWN',
|
||||
RAINBOW_KIT = 'RAINBOW_KIT',
|
||||
PRIVY = 'PRIVY',
|
||||
}
|
||||
|
||||
export enum StorageProvider {
|
||||
IPFS = 'IPFS',
|
||||
R2 = 'R2',
|
||||
VERCEL_BLOB = 'VERCEL_BLOB',
|
||||
SUPABASE_STORAGE = 'SUPABASE_STORAGE',
|
||||
}
|
||||
|
||||
export enum SiteMode {
|
||||
BASIC = 'BASIC',
|
||||
NOTE_TAKING = 'NOTE_TAKING',
|
||||
@@ -55,4 +41,5 @@ export enum ProviderType {
|
||||
GITHUB = 'GITHUB',
|
||||
WALLET = 'WALLET',
|
||||
FARCASTER = 'FARCASTER',
|
||||
PASSWORD = 'PASSWORD',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { GoogleLoginData, SessionData } from '@/lib/session'
|
||||
import { GoogleLoginData, LoginData, SessionData } from '@/lib/session'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { queryClient } from './queryClient'
|
||||
|
||||
@@ -20,19 +20,6 @@ async function fetchJson<JSON = unknown>(
|
||||
}).then((res) => res.json())
|
||||
}
|
||||
|
||||
function doLogin(url: string, { arg }: { arg: string }) {
|
||||
return fetchJson<SessionData>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: arg }),
|
||||
})
|
||||
}
|
||||
|
||||
function doLogout(url: string) {
|
||||
return fetchJson<SessionData>(url, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export default function useSession() {
|
||||
const { isPending, data: session } = useQuery({
|
||||
queryKey: ['session'],
|
||||
@@ -47,13 +34,14 @@ export default function useSession() {
|
||||
// const { trigger: logout } = useSWRMutation(sessionApiRoute, doLogout)
|
||||
// const { trigger: increment } = useSWRMutation(sessionApiRoute, doIncrement)
|
||||
|
||||
async function login(data: GoogleLoginData) {
|
||||
async function login(data: LoginData) {
|
||||
const res = await fetchJson<SessionData>(sessionApiRoute, {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
queryClient.setQueriesData({ queryKey: ['session'] }, res)
|
||||
return res
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"@wagmi/core": "^2.16.0",
|
||||
"ai": "^4.0.18",
|
||||
"array-move": "^4.0.0",
|
||||
"bcrypt-edge": "^0.1.0",
|
||||
"big.js": "^6.2.2",
|
||||
"case-anything": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -245,6 +245,9 @@ importers:
|
||||
array-move:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
bcrypt-edge:
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0
|
||||
big.js:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
@@ -3546,6 +3549,9 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
bcrypt-edge@0.1.0:
|
||||
resolution: {integrity: sha512-kTmsr8pHBxW6PwTc+jjAXw6eaHnxWaG5m058bYNftGZQEpG3ZwQrRip9f9RSFvUqLs50unwXdr/YrBIc1jbQsA==}
|
||||
|
||||
better-sqlite3@11.7.0:
|
||||
resolution: {integrity: sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==}
|
||||
|
||||
@@ -10967,6 +10973,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bcrypt-edge@0.1.0: {}
|
||||
|
||||
better-sqlite3@11.7.0:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getSessionOptions, SessionData } from '@/lib/session'
|
||||
import { getServerSession } from '@/lib/session'
|
||||
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
interface CreateContextOptions {
|
||||
// session: Session | null
|
||||
@@ -35,12 +33,7 @@ export type Context = Awaited<ReturnType<typeof createContextInner>> & {
|
||||
export async function createContext(opts: FetchCreateContextFnOptions) {
|
||||
// for API-response caching see https://trpc.io/docs/v11/caching
|
||||
const { req } = opts
|
||||
|
||||
const sessionOptions = getSessionOptions()
|
||||
const session = (await getIronSession(
|
||||
await cookies(),
|
||||
sessionOptions,
|
||||
)) as SessionData
|
||||
const session = await getServerSession()
|
||||
|
||||
return {
|
||||
token: {
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
CommentStatus,
|
||||
GateType,
|
||||
PostStatus,
|
||||
PostType,
|
||||
SiteMode,
|
||||
StorageProvider,
|
||||
UserRole,
|
||||
} from '@/lib/types'
|
||||
import { relations } from 'drizzle-orm'
|
||||
@@ -98,8 +96,8 @@ export const accounts = table(
|
||||
.default(''),
|
||||
providerInfo: text('providerInfo'),
|
||||
email: text('email', { length: 255 }),
|
||||
refreshToken: text('refreshToken', { length: 255 }),
|
||||
accessToken: text('accessToken', { length: 255 }),
|
||||
refreshToken: text('refreshToken', { length: 255 }),
|
||||
expiresAt: integer('expiresAt', { mode: 'timestamp' }),
|
||||
userId: text('userId').notNull(),
|
||||
createdAt: integer('createdAt', { mode: 'timestamp' })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { editorDefaultValue } from '@/lib/constants'
|
||||
import { AuthType, StorageProvider } from '@/lib/types'
|
||||
import { getUrl } from '@/lib/utils'
|
||||
import { Site } from '@penxio/types'
|
||||
import { db } from '../db'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AuthType, StorageProvider } from '@/lib/types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NETWORK, NetworkNames } from '@/lib/constants'
|
||||
import { ProviderType, UserRole } from '@/lib/types'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { genSaltSync, hashSync } from 'bcrypt-edge'
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import { createPublicClient, http } from 'viem'
|
||||
import { base, baseSepolia } from 'viem/chains'
|
||||
import { z } from 'zod'
|
||||
import { NETWORK, NetworkNames } from '@/lib/constants'
|
||||
import { ProviderType, UserRole } from '@/lib/types'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { db } from '../db'
|
||||
import { accounts, users } from '../db/schema'
|
||||
import { getEthPrice } from '../lib/getEthPrice'
|
||||
@@ -169,13 +170,13 @@ export const userRouter = router({
|
||||
.where(eq(users.id, input.userId))
|
||||
}),
|
||||
|
||||
myAccounts: publicProcedure.query(({ ctx }) => {
|
||||
myAccounts: protectedProcedure.query(({ ctx }) => {
|
||||
return db.query.accounts.findMany({
|
||||
where: eq(accounts.userId, ctx.token.uid),
|
||||
})
|
||||
}),
|
||||
|
||||
linkWallet: publicProcedure
|
||||
linkWallet: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
signature: z.string(),
|
||||
@@ -220,6 +221,33 @@ export const userRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
linkPassword: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const account = await db.query.accounts.findFirst({
|
||||
where: eq(accounts.providerAccountId, input.username),
|
||||
})
|
||||
|
||||
if (account) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This username already existed',
|
||||
})
|
||||
}
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId: ctx.token.uid,
|
||||
providerType: ProviderType.PASSWORD,
|
||||
providerAccountId: input.username,
|
||||
accessToken: await hashPassword(input.password),
|
||||
})
|
||||
}),
|
||||
|
||||
disconnectAccount: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -255,3 +283,8 @@ export const userRouter = router({
|
||||
return getEthPrice()
|
||||
}),
|
||||
})
|
||||
|
||||
async function hashPassword(password: string) {
|
||||
const salt = genSaltSync(10)
|
||||
return hashSync(password, salt)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 941 KiB After Width: | Height: | Size: 1.7 MiB |
Reference in New Issue
Block a user