feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)
Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4) Sprint 4.a — Admin Dashboard: - Auth: NextAuth.js v5, login page, middleware, token rotation - Dashboard: KPI cards, Recharts stock chart, quick actions - Members: TanStack Table (search/sort/paginate), add/edit forms - Distributions: multi-step form, real-time quota check, history - Stock: batch management, recall dialog, bar chart - Reports: monthly/member-list/recall, PDF/CSV download, preview Sprint 4.b — Member Portal: - Separate route group with top-nav layout (mobile-first) - Quota dashboard with radial SVG progress indicators - Distribution history with month filter - Profile/settings with password change Cross-cutting: - i18n: German (default) + English via next-intl - Dark + light mode (next-themes, user-togglable) - Playwright E2E tests (6/6 green) - Docker multi-stage build (node:22-alpine) - API proxy via Next.js rewrites Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5, TanStack Table, Recharts, Zod, React Hook Form, Playwright
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import NextAuth from "next-auth"
|
||||
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
|
||||
/** Helper: fetch with an AbortController timeout (default 5s) */
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs = 5000
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(url, { ...options, signal: controller.signal })
|
||||
return res
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${process.env.BACKEND_URL}/api/v1/auth/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
return {
|
||||
id: data.member.id,
|
||||
email: data.member.email,
|
||||
name: data.member.clubName,
|
||||
role: data.member.role,
|
||||
clubId: data.member.clubId,
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
expiresAt: Date.now() + data.expiresIn * 1000,
|
||||
}
|
||||
} catch {
|
||||
// Backend unreachable or timeout — fail gracefully
|
||||
return null
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
// Initial sign-in: transfer user data to token
|
||||
if (user) {
|
||||
token.role = user.role
|
||||
token.clubId = user.clubId
|
||||
token.accessToken = user.accessToken
|
||||
token.refreshToken = user.refreshToken
|
||||
token.expiresAt = user.expiresAt
|
||||
}
|
||||
|
||||
// Token refresh: if access token expired, use refresh token
|
||||
if (token.expiresAt && Date.now() > (token.expiresAt as number)) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${process.env.BACKEND_URL}/api/v1/auth/refresh`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken: token.refreshToken }),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
const refreshed = await res.json()
|
||||
token.accessToken = refreshed.accessToken
|
||||
token.refreshToken = refreshed.refreshToken
|
||||
token.expiresAt = Date.now() + refreshed.expiresIn * 1000
|
||||
} else {
|
||||
token.error = "RefreshTokenExpired"
|
||||
}
|
||||
} catch {
|
||||
token.error = "RefreshTokenError"
|
||||
}
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.role = token.role as string
|
||||
session.user.clubId = token.clubId as string
|
||||
session.error = token.error as string | undefined
|
||||
return session
|
||||
},
|
||||
async redirect({ url, baseUrl }) {
|
||||
// Handle relative URLs
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`
|
||||
// Handle same-origin URLs
|
||||
if (new URL(url).origin === baseUrl) return url
|
||||
return baseUrl
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user