Gate signup behind an invitation and revoke sessions on suspend
Registration was open to anyone who could reach the app. Signup now requires an invite: /invite/<token> validates the link and parks the token in an HttpOnly cookie, /join re-checks it and renders the form, and the register route consumes a use inside the same transaction that creates the user - so a duplicate email rolls the use back instead of burning it. The token never reaches the client, so the form cannot forge or replay one. The jwt callback now revalidates the user on every call and returns null when the account is missing or suspended, which clears the session cookie. Every API route and server component already branches on session?.user?.id, so this revokes access everywhere without editing any of them. The try/catch around that lookup is load-bearing: Auth.js treats a throw in this callback the same as a null return, so an unguarded transient database error would sign out every user at once. authorize() rejects non-ACTIVE users too, so a suspended account cannot sign in again to mint a fresh token. /register redirects to /join; it is linked from elsewhere and may be bookmarked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
src/app/(auth)/join/page.tsx
Normal file
99
src/app/(auth)/join/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { cookies } from "next/headers"
|
||||
import Link from "next/link"
|
||||
import { Beer, MailQuestion } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { JoinForm } from "@/components/auth/join-form"
|
||||
import { INVITE_COOKIE, inspectInvite, type InviteState } from "@/lib/invites"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const MESSAGES: Record<Exclude<InviteState, "valid">, string> = {
|
||||
unknown: "That invitation link isn't valid. Ask whoever invited you for a new one.",
|
||||
revoked: "That invitation has been revoked. Ask whoever invited you for a new one.",
|
||||
expired: "That invitation has expired. Ask whoever invited you for a new one.",
|
||||
exhausted: "That invitation has already been used. Ask whoever invited you for a new one.",
|
||||
}
|
||||
|
||||
const REQUIRED_MESSAGE =
|
||||
"DrinkTracker is invite only. Open the invitation link you were sent to create an account."
|
||||
|
||||
export default async function JoinPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string }
|
||||
}) {
|
||||
// Re-checked here rather than trusting the redirect from /invite/<token>: the
|
||||
// cookie may be stale, and the invite can be revoked or used up in between.
|
||||
const token = cookies().get(INVITE_COOKIE)?.value
|
||||
const { state, invite } = token
|
||||
? await inspectInvite(token)
|
||||
: { state: "missing" as const, invite: undefined }
|
||||
|
||||
if (state !== "valid") {
|
||||
// /invite/<token> sends the reason it rejected a link, and sets no cookie in
|
||||
// that case - so prefer it over the generic "you need an invitation".
|
||||
const reported = searchParams.error
|
||||
const fromLink =
|
||||
reported && reported in MESSAGES
|
||||
? MESSAGES[reported as Exclude<InviteState, "valid">]
|
||||
: null
|
||||
|
||||
const message =
|
||||
fromLink ??
|
||||
(state === "missing"
|
||||
? REQUIRED_MESSAGE
|
||||
: MESSAGES[state as Exclude<InviteState, "valid">])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<MailQuestion className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Invitation needed</CardTitle>
|
||||
<CardDescription>{message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Beer className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
|
||||
<CardDescription>
|
||||
{invite?.inviterName
|
||||
? `${invite.inviterName} invited you. Create an account to get started.`
|
||||
: "You've been invited. Create an account to get started."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JoinForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export default function LoginPage() {
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
|
||||
<Link href="/join" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,160 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { signIn } from "next-auth/react"
|
||||
import Link from "next/link"
|
||||
import { Beer, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
/**
|
||||
* Signup moved to /join, which requires an invitation. Kept as a redirect because
|
||||
* this path is linked from older pages and may be bookmarked.
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 10) {
|
||||
setError("Password must be at least 10 characters")
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||||
setError("Password must contain lowercase, uppercase, and a number")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Registration failed")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto sign in after successful registration
|
||||
await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: "/dashboard",
|
||||
})
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Beer className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
|
||||
<CardDescription>
|
||||
Create an account to start tracking your drinks
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Min 10 chars, upper+lower+number"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={128}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
Create Account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
redirect("/join")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { z } from "zod"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { rateLimit } from "@/lib/rate-limit"
|
||||
import { INVITE_COOKIE, InviteError, claimInvite } from "@/lib/invites"
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z
|
||||
@@ -48,30 +50,69 @@ export async function POST(request: Request) {
|
||||
|
||||
const { name, email, password } = result.data
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } })
|
||||
if (existingUser) {
|
||||
// Signup is invite only. The token is read from the HttpOnly cookie set by
|
||||
// /invite/<token>, never from the request body, so a caller cannot supply one.
|
||||
const inviteToken = cookies().get(INVITE_COOKIE)?.value
|
||||
if (!inviteToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "An account with this email already exists" },
|
||||
{ status: 409 }
|
||||
{ error: "An invitation is required to sign up." },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
// One transaction so a duplicate email rolls back the consumed invite use
|
||||
// rather than burning it on a failed signup.
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const invite = await claimInvite(tx, inviteToken)
|
||||
if (!invite) {
|
||||
throw new InviteError("This invitation link is no longer valid.", 403)
|
||||
}
|
||||
|
||||
const existingUser = await tx.user.findUnique({ where: { email } })
|
||||
if (existingUser) {
|
||||
throw new InviteError("An account with this email already exists", 409)
|
||||
}
|
||||
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
// The invite is the verification - there is no email infrastructure to
|
||||
// send a confirmation, and the link was delivered out of band.
|
||||
emailVerified: new Date(),
|
||||
role: "MEMBER",
|
||||
status: "ACTIVE",
|
||||
invitedById: invite.createdById,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
await tx.inviteRedemption.create({
|
||||
data: {
|
||||
inviteId: invite.id,
|
||||
userId: created.id,
|
||||
email,
|
||||
provider: "credentials",
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ id: user.id, name: user.name, email: user.email },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch {
|
||||
// Spent - do not leave it lying around for a second signup attempt.
|
||||
response.cookies.delete(INVITE_COOKIE)
|
||||
return response
|
||||
} catch (error) {
|
||||
if (error instanceof InviteError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
console.error("Registration error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Something went wrong. Please try again." },
|
||||
{ status: 500 }
|
||||
|
||||
36
src/app/invite/[token]/route.ts
Normal file
36
src/app/invite/[token]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invites"
|
||||
|
||||
/**
|
||||
* Entry point for an invite link or QR code.
|
||||
*
|
||||
* Deliberately a route handler rather than a page: a server component cannot set
|
||||
* cookies in Next 14, and stashing the token in an HttpOnly cookie keeps it off the
|
||||
* client entirely - the signup form never sees or submits it.
|
||||
*
|
||||
* This only parks the token. The use is consumed transactionally at registration,
|
||||
* so an abandoned signup does not burn an invite.
|
||||
*/
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { token: string } }
|
||||
) {
|
||||
const { state } = await inspectInvite(params.token)
|
||||
const origin = new URL(request.url).origin
|
||||
|
||||
if (state !== "valid") {
|
||||
return NextResponse.redirect(new URL(`/join?error=${state}`, origin))
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(new URL("/join", origin))
|
||||
response.cookies.set(INVITE_COOKIE, params.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: INVITE_COOKIE_MAX_AGE,
|
||||
})
|
||||
return response
|
||||
}
|
||||
145
src/components/auth/join-form.tsx
Normal file
145
src/components/auth/join-form.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { signIn } from "next-auth/react"
|
||||
import Link from "next/link"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
/**
|
||||
* Signup form for an invited user.
|
||||
*
|
||||
* The invite token is deliberately absent: it lives in an HttpOnly cookie set by
|
||||
* /invite/<token>, and the register route reads it server-side. Nothing here can
|
||||
* see or forge it.
|
||||
*/
|
||||
export function JoinForm() {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
return
|
||||
}
|
||||
if (password.length < 10) {
|
||||
setError("Password must be at least 10 characters")
|
||||
return
|
||||
}
|
||||
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||||
setError("Password must contain lowercase, uppercase, and a number")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Registration failed")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await signIn("credentials", { email, password, callbackUrl: "/dashboard" })
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Min 10 chars, upper+lower+number"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={128}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
Create Account
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { rateLimit } from "@/lib/rate-limit"
|
||||
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
|
||||
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
|
||||
*/
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/share"]
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/join", "/invite", "/share"]
|
||||
|
||||
// Email and password only. Google and GitHub were configured but never had
|
||||
// credentials set and no account ever linked to them, and dropping them keeps
|
||||
@@ -38,6 +38,10 @@ const providers = [
|
||||
const valid = await bcrypt.compare(password, user.password)
|
||||
if (!valid) return null
|
||||
|
||||
// Checked here as well as in the jwt callback, so a suspended user cannot
|
||||
// simply sign in again to mint a fresh token.
|
||||
if (user.status !== "ACTIVE") return null
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, image: user.image }
|
||||
},
|
||||
}),
|
||||
@@ -53,15 +57,45 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
/**
|
||||
* Revalidates the user on every call, so suspending or deleting an account
|
||||
* ends its session instead of waiting for the JWT to expire.
|
||||
*
|
||||
* Returning null clears the session cookie. Because every API route and server
|
||||
* component already branches on `session?.user?.id`, that revokes access
|
||||
* everywhere without touching any of them.
|
||||
*
|
||||
* The try/catch is load-bearing: Auth.js treats a throw here the same as a null
|
||||
* return, so an unguarded transient database error would sign out every user at
|
||||
* once. On failure we keep the existing token and let the next call retry.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
}
|
||||
const userId = token.id as string | undefined
|
||||
if (!userId) return token
|
||||
|
||||
try {
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true, status: true },
|
||||
})
|
||||
if (!current) return null
|
||||
if (current.status !== "ACTIVE") return null
|
||||
|
||||
token.role = current.role
|
||||
token.status = current.status
|
||||
} catch (error) {
|
||||
console.error("[auth] jwt revalidation failed, keeping session:", error)
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string
|
||||
session.user.role = token.role as "OWNER" | "MEMBER" | undefined
|
||||
}
|
||||
return session
|
||||
},
|
||||
|
||||
11
src/types/next-auth.d.ts
vendored
11
src/types/next-auth.d.ts
vendored
@@ -14,10 +14,7 @@ declare module "next-auth" {
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string
|
||||
role?: "OWNER" | "MEMBER"
|
||||
status?: "ACTIVE" | "SUSPENDED"
|
||||
}
|
||||
}
|
||||
// No JWT augmentation here on purpose. next-auth/jwt only re-exports
|
||||
// @auth/core/jwt, and augmenting either did not take - the callback's `token`
|
||||
// still resolves through JWT's `Record<string, unknown>` index signature. The
|
||||
// jwt/session callbacks in lib/auth.ts cast the two fields they read instead.
|
||||
|
||||
Reference in New Issue
Block a user