Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ yarn-error.log*

# idea files
.idea

certificates
19 changes: 5 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,18 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/passkey": "^1.5.5",
"@hookform/resolvers": "^3.9.1",
"@polinetwork/backend": "^0.14.0",
"@radix-ui/react-alert-dialog": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@polinetwork/backend": "file:../backend/package/dist",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-table": "^8.21.2",
"@trpc/client": "11.5.1",
"@trpc/next": "11.5.1",
"@trpc/react-query": "11.5.1",
"@trpc/tanstack-react-query": "11.5.1",
"better-auth": "^1.4.15",
"babel-plugin-react-compiler": "1.0.0",
"better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand All @@ -45,8 +34,10 @@
"next": "^15.5.9",
"next-themes": "^0.4.4",
"postgres": "^3.4.4",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^6.1.0",
"react-hook-form": "^7.55.0",
"server-only": "^0.0.1",
"sonner": "^2.0.3",
Expand Down
1,904 changes: 1,534 additions & 370 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Shape } from "@/components/shapes"

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<main className="grid grow place-content-center">
{children}
<div className="-z-10 pointer-events-none fixed inset-0">
<Shape variant="big-blue" className="left-1/2 top-0 -translate-x-1/2 -translate-y-1/3" />
<Shape variant="big-blue" className="left-0 top-0 translate-x-1/2 translate-y-1/3 opacity-60" />
<Shape variant="big-teal" className="left-0 top-0 -translate-x-1/3 translate-y-1/3 opacity-70" />
<Shape variant="big-teal" className="right-0 top-0 translate-x-1/3 -translate-y-1/3 opacity-45" />
</div>
</main>
)
}
File renamed without changes.
File renamed without changes.
196 changes: 196 additions & 0 deletions src/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"use client"

import { Key, Loader2, Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { FieldSeparator } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"
import { auth } from "@/lib/auth"

export default function LoginForm() {
const [email, setEmail] = useState("")
const [sent, setSent] = useState(false)

return (
<div className="flex flex-col gap-6 py-2 px-2">
{!sent ? (
<EmailCard email={email} onChange={(v) => setEmail(v)} onSend={() => setSent(true)} />
) : (
<OTPCard email={email} />
)}
</div>
)
// <FieldSeparator>Or continue with</FieldSeparator>
// <Button onClick={loginWithPasskey} className="w-full"><KeyRound size={16} /> Login with Passkey</Button>
}

function EmailCard({
email,
onChange,
onSend,
}: {
email: string
onChange: (value: string) => void
onSend: () => void
}) {
const [loading, setLoading] = useState(false)
const [passkeyLoading, setPasskeyLoading] = useState(false)
const router = useRouter()

async function sendOtp() {
const { data, error } = await auth.emailOtp.sendVerificationOtp({
type: "sign-in",
email,
})

if (data?.success) {
onSend()
toast.success("OTP sent. Check your inbox and spam!")
return
}

if (error?.code === "INVALID_EMAIL") {
toast.error("Invalid email")
return
}

toast.error("There was an unexpected error")
console.error({ error })
}

async function passkeyLogin() {
setPasskeyLoading(true)
const { data, error } = await auth.signIn.passkey()
if (error || !data) toast.error("Unable to login with passkey")
else {
toast.success("Login successful")
router.replace("/dashboard")
}
setPasskeyLoading(false)
}

return (
<div className="w-full flex flex-col gap-8">
<form
className="grid gap-4"
onSubmit={async (e) => {
e.preventDefault()
setLoading(true)
await sendOtp()
setLoading(false)
}}
>
<div className="flex gap-2 flex-col items-start justify-start">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="mario.rossi@example.org"
className="bg-card w-auto"
required
onChange={(e) => {
onChange(e.target.value)
}}
value={email}
autoComplete="email webauthn"
/>
</div>
<Button type="submit" disabled={loading} className="basis-9 group">
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Mail size={16} />
Login with OTP
</>
)}
</Button>
</form>
<FieldSeparator>Or continue with</FieldSeparator>
<Button onClick={passkeyLogin} disabled={passkeyLoading} className="basis-9 group" variant="secondary">
{passkeyLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Key size={16} /> Login with Passkey
</>
)}
</Button>
</div>
)
}

function OTPCard({ email }: { email: string }) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [otp, setOtp] = useState("")

async function verifyOtp() {
const { data, error } = await auth.signIn.emailOtp({
email,
otp,
fetchOptions: {
onRequest: () => {
setLoading(true)
},
onResponse: () => {
setLoading(false)
},
},
})

if (data) {
toast.success("Successfully logged in!")
return router.replace("/dashboard")
}

if (error.code === "INVALID_OTP") toast.error("You entered an invalid OTP")
else {
toast.error("There was an unexpected error")
console.error({ error })
}
}
return (
<form
className="grid gap-4 items-center"
onSubmit={async (e) => {
e.preventDefault()
await verifyOtp()
}}
>
<p className="max-w-80 text-sm text-muted-foreground text-center">
Check your <span className="text-foreground">{email}</span> inbox and spam to get the OTP.
</p>
<div className="grid gap-2">
<Label htmlFor="email">OTP</Label>
<InputOTP
pushPasswordManagerStrategy="none"
maxLength={6}
value={otp}
onChange={(v) => setOtp(v)}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
type="text"
>
<InputOTPGroup>
<InputOTPSlot className="bg-card" index={0} />
<InputOTPSlot className="bg-card" index={1} />
<InputOTPSlot className="bg-card" index={2} />
<InputOTPSlot className="bg-card" index={3} />
<InputOTPSlot className="bg-card" index={4} />
<InputOTPSlot className="bg-card" index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<Button disabled={loading} className="self-stretch gap-2" onClick={verifyOtp}>
{loading ? <Loader2 size={16} className="animate-spin" /> : "Verify"}
</Button>
</form>
)
}
19 changes: 19 additions & 0 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation"
import { Card } from "@/components/ui/card"
import { getServerSession } from "@/server/auth"
import LoginForm from "./login-form"

export default async function Page() {
const { data: session } = await getServerSession()
if (session) return redirect("/dashboard")

return (
<main className="w-full flex flex-col flex-1 items-center justify-center max-w-3xl">
<Card className="flex w-full bg-background flex-col items-center p-6">
<h1 className="text-2xl font-bold mb-2 py-1">Login</h1>
<p className="text-lg md:text-sm text-muted-foreground mb-6">Login with Email OTP or Passkey</p>
<LoginForm />
</Card>
</main>
)
}
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { auth } from "../../../lib/auth"
import { auth } from "../../../../lib/auth"

export function Logout({ email }: { email: string }) {
const router = useRouter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { APIError } from "better-auth/api"
import { CircleCheckBig, ClockAlertIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Code } from "@/components/code"
import { InputWithPrefix } from "@/components/input-prefix"
import { Button } from "@/components/ui/button"
Expand Down Expand Up @@ -120,11 +121,25 @@ export function TelegramLink({ botUsername }: { botUsername: string }) {

return savedLink && timeLeft ? (
<div className="flex flex-col items-center gap-4">
<p className="flex items-center justify-between gap-4 text-4xl">
{savedLink.code.split("").map((c, i) => (
<span key={i}>{c}</span>
))}
</p>
<button
type="button"
className="cursor-pointer"
onClick={async () => {
try {
await navigator.clipboard.writeText(savedLink.code)
toast.success("Code copied to clipboard!")
} catch (err) {
toast.error("Cannot copy code to clipboard")
console.error(err)
}
}}
>
<p className="flex items-center justify-between gap-4 text-4xl">
{savedLink.code.split("").map((c, i) => (
<span key={i}>{c}</span>
))}
</p>
</button>
<Timer ttl={savedLink.ttl} timeLeft={timeLeft} onEnd={() => setExpired(true)} />

<div className="flex items-center gap-2">
Expand Down
53 changes: 53 additions & 0 deletions src/app/dashboard/(active)/account/delete-passkey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client"

import { Trash2Icon } from "lucide-react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { auth } from "@/lib/auth"

export function DeletePasskey({ id }: { id: string }) {
const router = useRouter()

async function deletePasskey() {
const { data, error } = await auth.passkey.deletePasskey({ id })
if (!data || error) return toast.error("There was an error")
toast.success("Passkey deleted!")
router.refresh()
}

return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Remove passkey</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete this passkey?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={deletePasskey} variant="destructive">
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
Loading
Loading