mirror of
https://github.com/tomru/pfadi-bussle.git
synced 2026-03-03 22:47:15 +01:00
migrate auth from next-auth to better-auth with magic link support
Replace next-auth with better-auth, adding magic link email login as the primary auth method and GitHub OAuth as an optional social provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,42 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useSession, signIn } from 'next-auth/react'
|
||||
import { useState } from 'react'
|
||||
import { useSession, signIn } from '../lib/auth-client'
|
||||
import Input from './input'
|
||||
import Button from './button'
|
||||
|
||||
export default function Auth({ children }) {
|
||||
const { data: session, status } = useSession()
|
||||
const { data: session, isPending } = useSession()
|
||||
const isUser = !!session?.user
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return // Do nothing while loading
|
||||
if (!isUser) signIn() // If not authenticated, force log in
|
||||
}, [isUser, status])
|
||||
if (isPending) return <div>Loading...</div>
|
||||
|
||||
if (isUser) {
|
||||
return children
|
||||
if (isUser) return children
|
||||
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ENABLED) {
|
||||
signIn.social({ provider: "github", callbackURL: window.location.href })
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
// Session is being fetched, or no user.
|
||||
// If no user, useEffect() will redirect.
|
||||
return <div>Loading...</div>
|
||||
if (sent) return <div>E-Mail verschickt — bitte prüfe dein Postfach.</div>
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const result = await signIn.magicLink({ email, callbackURL: window.location.href })
|
||||
if (result.error) setError(result.error.message)
|
||||
else setSent(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-sm mx-auto mt-16">
|
||||
<Input label="E-Mail" name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
{error && <p className="text-red-500 text-sm mb-3">{error}</p>}
|
||||
<Button type="submit" loading={loading}>Magic Link senden</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { signIn } from '../lib/auth-client'
|
||||
|
||||
export default function Denied() {
|
||||
return (
|
||||
@@ -8,7 +8,12 @@ export default function Denied() {
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ENABLED) {
|
||||
signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Melde dich an um diese Seite zu sehen.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import { useSession, signOut } from '../lib/auth-client'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function User() {
|
||||
const { data, status } = useSession()
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
if (status === 'loading' || !data?.user?.email) {
|
||||
if (isPending || !session?.user?.email) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function User() {
|
||||
Admin
|
||||
</Link>
|
||||
<div className="font-extrabold bg-red-400 px-2 py-1 mr-3 rounded-xs">
|
||||
{data.user.email}
|
||||
{session.user.email}
|
||||
</div>
|
||||
<button onClick={() => signOut()} className="btn btn-blue">
|
||||
Logout
|
||||
|
||||
@@ -10,4 +10,4 @@ if (process.env.SITE_URL) {
|
||||
|
||||
export function getBaseURL(): string {
|
||||
return URL
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import Auth from '../components/auth'
|
||||
|
||||
// This is the HOC
|
||||
function withAuth(WrappedComponent: React.FunctionComponent) {
|
||||
// Return a new component
|
||||
function withAuth({ session, ...pageProps }) {
|
||||
function withAuth(pageProps) {
|
||||
// Render the WrappedComponent with additional props
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Auth>
|
||||
<WrappedComponent {...pageProps} />
|
||||
</Auth>
|
||||
</SessionProvider>
|
||||
<Auth>
|
||||
<WrappedComponent {...pageProps} />
|
||||
</Auth>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
8
lib/auth-client.ts
Normal file
8
lib/auth-client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
import { magicLinkClient } from "better-auth/client/plugins"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient()],
|
||||
})
|
||||
|
||||
export const { signIn, signOut, signUp, useSession } = authClient
|
||||
92
lib/auth.ts
Normal file
92
lib/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { magicLink } from "better-auth/plugins"
|
||||
import { mongodbAdapter } from "better-auth/adapters/mongodb"
|
||||
import { MongoClient, ServerApiVersion } from "mongodb"
|
||||
import { MONGO_URI } from "../db"
|
||||
import nodemailer from "nodemailer"
|
||||
|
||||
async function sendEmail({ to, subject, url }: { to: string; subject: string; url: string }) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: "wirtanen.uberspace.de",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
})
|
||||
await transporter.sendMail({
|
||||
from: process.env.FROM_EMAIL,
|
||||
to,
|
||||
subject,
|
||||
text: url,
|
||||
html: `<p>Click <a href="${url}">here</a> to sign in.</p>`,
|
||||
})
|
||||
}
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
||||
const GITHUB_USERS_GRANTED = ['111471']
|
||||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
|
||||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
|
||||
const GITHUB_ENABLED = Boolean(GITHUB_CLIENT_SECRET?.length && GITHUB_CLIENT_ID?.length);
|
||||
|
||||
const client = new MongoClient(MONGO_URI, {
|
||||
serverApi: {
|
||||
version: ServerApiVersion.v1,
|
||||
strict: true,
|
||||
deprecationErrors: true,
|
||||
}
|
||||
})
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: mongodbAdapter(client.db()),
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendEmail({ to: email, subject: "Sign in to Pfadi Bussle", url })
|
||||
},
|
||||
}),
|
||||
],
|
||||
...(GITHUB_ENABLED ? {
|
||||
socialProviders: {
|
||||
github: {
|
||||
provider: 'github',
|
||||
clientId: GITHUB_CLIENT_ID,
|
||||
clientSecret: GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["github"],
|
||||
},
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
onRequest: async (request) => {
|
||||
// Authorization logic
|
||||
const session = request.context?.session
|
||||
if (session?.user) {
|
||||
const account = request.context?.account
|
||||
|
||||
// GitHub provider check
|
||||
if (account?.providerId === 'github') {
|
||||
if (!GITHUB_USERS_GRANTED.includes(account.providerAccountId)) {
|
||||
throw new Error('Unauthorized GitHub user')
|
||||
}
|
||||
}
|
||||
|
||||
// Email check - only allow admin email
|
||||
if (session.user.email && session.user.email !== ADMIN_EMAIL) {
|
||||
throw new Error('Unauthorized email')
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
1640
package-lock.json
generated
1640
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,19 +14,19 @@
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next-auth/mongodb-adapter": "^1.1.3",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@types/mdx": "^2.0.11",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"better-auth": "^1.4.18",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"googleapis": "^171.4.0",
|
||||
"ics": "^3.8.1",
|
||||
"mongodb": "^7.1.0",
|
||||
"mongoose": "^9.2.1",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-axiom": "^1.10.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -53,4 +53,4 @@
|
||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
pages/api/auth/[...all].ts
Normal file
37
pages/api/auth/[...all].ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { auth } from "../../../lib/auth"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000"
|
||||
const url = new URL(req.url!, baseUrl)
|
||||
|
||||
let body: Buffer | undefined
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
body = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)))
|
||||
req.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
const webRequest = new Request(url.toString(), {
|
||||
method: req.method,
|
||||
headers: req.headers as HeadersInit,
|
||||
body: body,
|
||||
})
|
||||
|
||||
const response = await auth.handler(webRequest)
|
||||
|
||||
res.status(response.status)
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value)
|
||||
})
|
||||
res.end(await response.text())
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import NextAuth from 'next-auth'
|
||||
import EmailProvider from 'next-auth/providers/email'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
|
||||
import { MongoDBAdapter } from '@next-auth/mongodb-adapter'
|
||||
import { MONGO_URI } from '../../../db'
|
||||
import { MongoClient, ServerApiVersion } from 'mongodb'
|
||||
|
||||
let client: MongoClient
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
||||
const GITHUB_USERS_GRANTED = ['111471']
|
||||
|
||||
async function getMongoClient() {
|
||||
if (!client) {
|
||||
client = new MongoClient(MONGO_URI, {
|
||||
serverApi: {
|
||||
version: ServerApiVersion.v1,
|
||||
strict: true,
|
||||
deprecationErrors: true,
|
||||
}
|
||||
})
|
||||
await client.connect()
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
return await NextAuth(req, res, {
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
adapter: MongoDBAdapter(getMongoClient()),
|
||||
providers: [
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
}),
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: "wirtanen.uberspace.de",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
logger: true,
|
||||
debug: true,
|
||||
},
|
||||
from: process.env.FROM_EMAIL,
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ account, email }) {
|
||||
// if user sigin requested magic link via EmailProvider
|
||||
if (account.provider === 'email') {
|
||||
if (email?.verificationRequest) {
|
||||
// only allow admins by email entered
|
||||
return account.providerAccountId === ADMIN_EMAIL
|
||||
}
|
||||
|
||||
// if user accesses with magic link, also only allow admin
|
||||
return account.providerAccountId === ADMIN_EMAIL
|
||||
} else if (account.provider === 'github') {
|
||||
// only one and only one user
|
||||
return GITHUB_USERS_GRANTED.includes(account.providerAccountId)
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user