Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
af2dd2c
feat: add authentication feature
samthelightbender Sep 24, 2025
bbcdeaa
migration: remove table that unrelated with authentication feature
samthelightbender Sep 25, 2025
6c6acc9
lint: fix lint error
samthelightbender Sep 25, 2025
8ffa2e7
Fix: Update Prisma schema path in npm scripts
samthelightbender Sep 25, 2025
dce8983
Update .env.example
samthelightbender Sep 25, 2025
c0c73a6
Refines Prisma configuration and updates linting scripts
zainphp Sep 25, 2025
65eecb0
Prepares schema for initial authentication feature
zainphp Sep 25, 2025
fdf6cd5
Implements social media authentication options.
zainphp Sep 25, 2025
9b44baa
remove unnecessary columns
zainphp Sep 25, 2025
9750a99
fix lint error
zainphp Sep 25, 2025
0ef5f0d
Merge branch 'pemrogrammer:main' into auth
samthelightbender Sep 26, 2025
690c9a7
implement client login form error handling
samthelightbender Sep 29, 2025
9e11ef9
registration and email verification using otp (without smtp)
samthelightbender Sep 29, 2025
19c2023
update login: remove email param to email-verification
samthelightbender Sep 29, 2025
9e5ded7
add smtp support for OTP using Resend
samthelightbender Sep 30, 2025
ee77a09
add router dependency to fix lint
samthelightbender Sep 30, 2025
eefd79d
Improves login form layout and workflow for improved user experience.
zainphp Sep 30, 2025
33295d3
feat(auth): add admin plugin to better auth and convert seed users to…
samthelightbender Aug 30, 2026
78a65ac
feat(auth): add Google OAuth support to register page and update auth…
samthelightbender Aug 30, 2026
e8217e8
feat(auth): add GitHub OAuth support to register page and enable acco…
samthelightbender Aug 30, 2026
571d5ad
feat(landing): update header with authenticated profile dropdown and …
samthelightbender Aug 30, 2026
6e91655
chore(merge): resolve merge conflicts with origin/main
samthelightbender Aug 30, 2026
6a60063
Merge pull request #1 from samthelightbender/auth
samthelightbender Aug 30, 2026
f9c078c
feat: add auth unit and integration tests
samthelightbender Aug 31, 2026
bc1f85d
Merge branch 'feat/auth-tests'
samthelightbender Aug 31, 2026
d1e24d4
feat: add pino logger configuration
samthelightbender Aug 31, 2026
fffcfc3
Revert logger support from auth and main
samthelightbender Sep 1, 2026
d44aa6e
Fix username login redirect
samthelightbender Sep 1, 2026
93e8d00
feat(auth): update email verification flow with session-based email a…
samthelightbender Sep 1, 2026
b355d93
feat(auth): include remaining files in email verification flow
samthelightbender Sep 1, 2026
1f1df8f
refactor(auth): extract authentication form components
samthelightbender Sep 2, 2026
5720097
fix(auth): redirect verified users from email verification
samthelightbender Sep 2, 2026
3496c4d
feat(auth): confirm logout from header
samthelightbender Sep 2, 2026
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
13,502 changes: 5,611 additions & 7,891 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"lucide-react": "^0.544.0",
"next": "16.0.10",
"postcss": "^8.5.6",
"radix-ui": "^1.6.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.63.0",
"resend": "^6.1.1",
"tailwind-merge": "^3.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
Warnings:

- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.

*/
-- AlterTable
ALTER TABLE "public"."user" ADD COLUMN "displayUsername" TEXT,
ADD COLUMN "username" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "public"."user"("username");
8 changes: 8 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ model User {
image String?
sessions Session[]

username String? @unique
displayUsername String?

banned Boolean? @default(false)
banReason String?
banExpires DateTime?

@@map("user")
}

Expand Down Expand Up @@ -151,6 +158,7 @@ model Account {
enum Role {
USER
MODERATOR
ADMIN
}

model Session {
Expand Down
52 changes: 29 additions & 23 deletions prisma/seeds/user.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
'use server'

import bcrypt from 'bcryptjs'
import { nanoid } from 'nanoid'
import { auth } from '@/lib/auth'
import prisma from '../prisma'

export async function seedUsers() {
const userId = nanoid()
const email = 'hello@codeshowcase.dev'

const hashedPassword = await bcrypt.hash(
(process.env.DEFAULT_PASSWORD as string) || 'Password1',
10
)
const existingUser = await prisma.user.findUnique({
where: { email },
})

if (existingUser) {
console.log('🌱 Super Admin user already exists, skipping creation.')
return
}

await prisma.user.upsert({
where: { email: 'hello@codeshowcase.dev' },
update: {}, // kalau sudah ada, tidak perlu update
create: {
id: userId,
const res = await auth.api.createUser({
body: {
email,
password: (process.env.DEFAULT_PASSWORD as string) || 'Password1',
name: 'Super Admin',
email: 'hello@codeshowcase.dev',
image: process.env.DEFAULT_USER_IMAGE,
emailVerified: true,
role: 'MODERATOR',
accounts: {
create: [
{
accountId: userId,
providerId: 'email-password',
password: hashedPassword,
},
],
data: {
username: 'superadmin',
displayUsername: 'Super Admin',
image: process.env.DEFAULT_USER_IMAGE,
},
},
})

if (res?.user?.id) {
await prisma.user.update({
where: { id: res.user.id },
data: {
emailVerified: true,
},
})
console.log('✅ Super Admin created via auth.api.createUser.')
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
'use client'

import { useEffect, useEffectEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import authClient from '@/lib/auth-client'

const COOLDOWN_SECONDS = 60
const COOLDOWN_STORAGE_KEY = 'verification_cooldown_timestamp'

const formSchema = z.object({
otp: z.string().length(6, { message: 'Your code must be 6 digits.' }),
})

export function EmailVerificationForm() {
const [loading, setLoading] = useState(false)
const [isSending, setIsSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
const [email, setEmail] = useState('')
const [isSent, setIsSent] = useState(false)
const [isVerified, setIsVerified] = useState(false)
const [redirectCountdown, setRedirectCountdown] = useState(5)
const router = useRouter()

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { otp: '' },
})

useEffect(() => {
if (isVerified && redirectCountdown > 0) {
const timer = setTimeout(() => setRedirectCountdown((prev) => prev - 1), 1000)
return () => clearTimeout(timer)
} else if (isVerified && redirectCountdown === 0) {
router.push('/feeds')
}
}, [isVerified, redirectCountdown, router])

const handleEmail = useEffectEvent(async () => {
const { data, error } = await authClient.getSession()
if (data?.user?.email) {
setEmail(data.user.email)
} else {
router.push('/auth/login')
}
})

useEffect(() => {
handleEmail()
}, [])

const handleCoolDown = useEffectEvent(() => {
if (isVerified) return

const cooldownEndTime = parseInt(localStorage.getItem(COOLDOWN_STORAGE_KEY) || '0')
if (cooldownEndTime > Date.now()) {
const remainingSeconds = Math.ceil((cooldownEndTime - Date.now()) / 1000)
setCooldown(remainingSeconds)
setIsSent(true)
}

return setInterval(() => {
setCooldown((prev) => (prev > 0 ? prev - 1 : 0))
}, 1000)
})

useEffect(() => {
const timer = handleCoolDown()
return () => {
if (timer) clearInterval(timer)
}
}, [isVerified])

const handleSend = async () => {
if (isSending) return
setIsSending(true)

const { error } = await authClient.emailOtp.sendVerificationOtp({
email: email,
type: 'email-verification',
})

if (!error) {
const cooldownEndTime = Date.now() + COOLDOWN_SECONDS * 1000
localStorage.setItem(COOLDOWN_STORAGE_KEY, cooldownEndTime.toString())
setCooldown(COOLDOWN_SECONDS)
setIsSent(true)
}
setIsSending(false)
}

const handleResend = async () => {
if (cooldown > 0 || isSending) return
setIsSending(true)

const { error } = await authClient.emailOtp.sendVerificationOtp({
email: email,
type: 'email-verification',
})

if (!error) {
const cooldownEndTime = Date.now() + COOLDOWN_SECONDS * 1000
localStorage.setItem(COOLDOWN_STORAGE_KEY, cooldownEndTime.toString())
setCooldown(COOLDOWN_SECONDS)
}
setIsSending(false)
}

const onSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true)
await authClient.emailOtp.verifyEmail(
{ email: email, otp: values.otp },
{
onSuccess: async () => {
localStorage.removeItem(COOLDOWN_STORAGE_KEY)
setIsVerified(true)
},
onError: (error) => {
form.setError('otp', { type: 'server', message: error.error.message })
setLoading(false)
},
}
)
}

return (
<>
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Check Your Email</CardTitle>
<CardDescription>
{isSent
? 'Enter the 6-digit code sent to your email.'
: email
? `An image verification OTP will be sent to ${email}.`
: 'Please wait...'}
</CardDescription>
</CardHeader>
<CardContent>
{!isSent ? (
<Button className="w-full" onClick={handleSend} disabled={isSending}>
{isSending ? <Loader2 className="animate-spin" /> : 'Send Verification Code'}
</Button>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Verification Code</FormLabel>
<FormControl>
<Input
placeholder="6-digit code"
className="text-center text-lg tracking-widest"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : 'Verify Account'}
</Button>
</form>
</Form>
)}

{isSent && (
<div className="mt-4 text-center text-sm text-muted-foreground">
<span>Didn&apos;t receive the code?</span>
<Button
variant="link"
className="px-1 font-semibold"
disabled={cooldown > 0 || isSending}
onClick={handleResend}
>
{isSending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{cooldown > 0 ? `Resend in ${cooldown}s` : 'Click to resend'}
</Button>
</div>
)}
</CardContent>
</Card>

{isVerified && (
<AlertDialog open={isVerified}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>You are now verified!</AlertDialogTitle>
<AlertDialogDescription>
Your email has been successfully verified. You can now access all features.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => router.push('/feeds')}>
Continue to Feed ({redirectCountdown}s)
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
)
}
Loading
Loading