feat: improve login

This commit is contained in:
0xzio
2024-12-18 23:31:27 +08:00
parent 24e27bdb16
commit fff1587c21
32 changed files with 595 additions and 125 deletions

View File

@@ -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>",
"^~(.*)$",
"^@(.*)$",
"^[./]"
]
}
}

View File

@@ -55,6 +55,7 @@ export async function GET(req: NextRequest) {
email,
name: name || '',
displayName: name,
image: picture,
google: JSON.stringify({
name,
email,

View File

@@ -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 })
}

View File

@@ -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">

View File

@@ -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 {}
}
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>
)}

View File

@@ -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={() => {

View File

@@ -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>
)
}

View File

@@ -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 () => {

View File

@@ -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" />

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }
}

View 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>
)
}

View 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>
)
}

View 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 }
}

View File

@@ -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>
)

View File

@@ -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'

View File

@@ -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({

View File

@@ -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',
}

View File

@@ -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() {

View File

@@ -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
View File

@@ -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

View File

@@ -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: {

View File

@@ -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' })

View File

@@ -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'

View File

@@ -1,4 +1,3 @@
import { AuthType, StorageProvider } from '@/lib/types'
import { eq } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

View File

@@ -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