mirror of
https://github.com/tomru/pfadi-bussle.git
synced 2026-03-04 06:57:12 +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 { useState } from 'react'
|
||||||
|
import { useSession, signIn } from '../lib/auth-client'
|
||||||
import { useSession, signIn } from 'next-auth/react'
|
import Input from './input'
|
||||||
|
import Button from './button'
|
||||||
|
|
||||||
export default function Auth({ children }) {
|
export default function Auth({ children }) {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, isPending } = useSession()
|
||||||
const isUser = !!session?.user
|
const isUser = !!session?.user
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
if (isPending) return <div>Loading...</div>
|
||||||
if (status === 'loading') return // Do nothing while loading
|
|
||||||
if (!isUser) signIn() // If not authenticated, force log in
|
|
||||||
}, [isUser, status])
|
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) return children
|
||||||
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 (sent) return <div>E-Mail verschickt — bitte prüfe dein Postfach.</div>
|
||||||
// If no user, useEffect() will redirect.
|
|
||||||
return <div>Loading...</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() {
|
export default function Denied() {
|
||||||
return (
|
return (
|
||||||
@@ -8,7 +8,12 @@ export default function Denied() {
|
|||||||
<a
|
<a
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
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.
|
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'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function User() {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export default function User() {
|
|||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
<div className="font-extrabold bg-red-400 px-2 py-1 mr-3 rounded-xs">
|
<div className="font-extrabold bg-red-400 px-2 py-1 mr-3 rounded-xs">
|
||||||
{data.user.email}
|
{session.user.email}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => signOut()} className="btn btn-blue">
|
<button onClick={() => signOut()} className="btn btn-blue">
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ if (process.env.SITE_URL) {
|
|||||||
|
|
||||||
export function getBaseURL(): string {
|
export function getBaseURL(): string {
|
||||||
return URL
|
return URL
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { SessionProvider } from 'next-auth/react'
|
|
||||||
import Auth from '../components/auth'
|
import Auth from '../components/auth'
|
||||||
|
|
||||||
// This is the HOC
|
// This is the HOC
|
||||||
function withAuth(WrappedComponent: React.FunctionComponent) {
|
function withAuth(WrappedComponent: React.FunctionComponent) {
|
||||||
// Return a new component
|
// Return a new component
|
||||||
function withAuth({ session, ...pageProps }) {
|
function withAuth(pageProps) {
|
||||||
// Render the WrappedComponent with additional props
|
// Render the WrappedComponent with additional props
|
||||||
return (
|
return (
|
||||||
<SessionProvider session={session}>
|
<Auth>
|
||||||
<Auth>
|
<WrappedComponent {...pageProps} />
|
||||||
<WrappedComponent {...pageProps} />
|
</Auth>
|
||||||
</Auth>
|
|
||||||
</SessionProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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",
|
"@date-fns/tz": "^1.2.0",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next-auth/mongodb-adapter": "^1.1.3",
|
|
||||||
"@next/mdx": "^16.1.6",
|
"@next/mdx": "^16.1.6",
|
||||||
"@types/mdx": "^2.0.11",
|
"@types/mdx": "^2.0.11",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
|
"better-auth": "^1.4.18",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"ics": "^3.8.1",
|
"ics": "^3.8.1",
|
||||||
|
"mongodb": "^7.1.0",
|
||||||
"mongoose": "^9.2.1",
|
"mongoose": "^9.2.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
|
||||||
"next-axiom": "^1.10.0",
|
"next-axiom": "^1.10.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -53,4 +53,4 @@
|
|||||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
"^.+\\.(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