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:
Patrick Plate
2026-06-12 17:18:38 +02:00
parent a1d4ba44e3
commit fe6e96dd3f
143 changed files with 23568 additions and 0 deletions
+123
View File
@@ -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",
},
})