feat(auth): integrate NextAuth v5 with credentials provider, protected routes, and custom auth layouts

This commit is contained in:
Chneemann 2026-08-03 15:13:18 +02:00
parent 47b89056b5
commit fd728634ce
No known key found for this signature in database
16 changed files with 405 additions and 48 deletions

View file

@ -1,22 +1,29 @@
# Flowstate # Flowstate
A modern, high-performance To-Do web application designed to help you lock in and enter the productivity flow. A modern, high-performance Kanban & Workflow web application designed to help you lock in and enter the productivity flow.
![Next.js](https://img.shields.io/badge/Next.js-16-black?style=flat-square&logo=next.js) ![Next.js](https://img.shields.io/badge/Next.js-16-black?style=flat-square&logo=next.js)
![React](https://img.shields.io/badge/React-19-blue?style=flat-square&logo=react) ![React](https://img.shields.io/badge/React-19-blue?style=flat-square&logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?style=flat-square&logo=typescript) ![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?style=flat-square&logo=typescript)
![Tailwind CSS](https://img.shields.io/badge/Tailwind-v4-38bdf8?style=flat-square&logo=tailwind-css) ![Tailwind CSS](https://img.shields.io/badge/Tailwind-v4-38bdf8?style=flat-square&logo=tailwind-css)
![Auth.js](https://img.shields.io/badge/Auth.js-v5-purple?style=flat-square)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql)
![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis)
## 🛠️ Tech Stack ## 🛠️ Tech Stack
- **Framework:** [Next.js 16](https://nextjs.org/) (App Router) - **Framework:** [Next.js 16](https://nextjs.org/) (App Router, Route Groups)
- **UI & Styling:** [React 19](https://react.dev/), [Tailwind CSS v4](https://tailwindcss.com/) - **UI & Styling:** [React 19](https://react.dev/), [Tailwind CSS v4](https://tailwindcss.com/)
- **Backend:** [Node.js](https://nodejs.org/) - **Authentication:** [Auth.js v5 (NextAuth)](https://authjs.dev/) with Credentials Provider
- **Database:** [PostgreSQL 16](https://www.postgresql.org/) - **Database & State:** PostgreSQL / Prisma (in progress)
- **Caching & Queue:** [Redis 7](https://redis.io/)
## 📂 Architecture & Structure
The project uses Next.js Route Groups to separate public and protected application areas cleanly:
- `app/(auth)/` — Public authentication routes (Login, Register)
- `app/(app)/` — Protected workspace & dashboard routes (Sidebar, Header, Kanban Board)
- `app/api/` — Backend API endpoints & Auth handlers
## 🎯 Current Status ## 🎯 Current Status
_Work in Progress_ — Exploring ideas, setting up the architecture, and defining core features. The journey has just begun! _In Progress_ — Core layout structure, responsive navigation, and authentication (Auth.js v5) are fully set up. Next up: Kanban board columns and task management features.

6
app/(app)/board/page.tsx Normal file
View file

@ -0,0 +1,6 @@
/**
* Landing page
*/
export default function Home() {
return <div className="space-y-6">Home</div>;
}

34
app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,34 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Sidebar from "@/app/components/layout/Sidebar";
import Header from "@/app/components/layout/Header";
import Navbar from "@/app/components/layout/Navbar";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = typeof auth === "function" ? await auth() : null;
if (!session) {
redirect("/login");
}
return (
<>
<div className="hidden md:flex">
<Sidebar />
</div>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden pb-16 md:pb-0">
<Header />
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
{children}
</main>
<div className="md:hidden flex">
<Navbar />
</div>
</div>
</>
);
}

11
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-dvh w-full items-center justify-center p-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}

130
app/(auth)/login/page.tsx Normal file
View file

@ -0,0 +1,130 @@
/**
* @file LoginPage.tsx
* @description Client component providing a user login interface with NextAuth credentials authentication.
*/
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
/**
* Renders the login page containing the authentication form, error handling,
* and navigation links for user sign-in.
*
* @returns {JSX.Element} The rendered login page component.
*/
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/**
* Handles the submission of the login form, validates credentials via NextAuth,
* and manages loading/error states or redirects upon success.
*
* @async
* @param {React.SubmitEvent<HTMLFormElement>} e - The form submission event.
* @returns {Promise<void>}
*/
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.error) {
if (res.error === "CredentialsSignin") {
setError("Incorrect email address or password.");
} else {
setError("An unexpected error has occurred.");
}
setLoading(false);
} else {
router.push("/");
router.refresh();
}
} catch {
setError("Connection error. Please try again later.");
setLoading(false);
}
};
return (
<div className="flex h-dvh w-full items-center justify-center">
{/* Login Card Container */}
<div className="w-full max-w-sm p-6 space-y-6 border border-border rounded-xl bg-card backdrop-blur-md">
{/* Header Section */}
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold tracking-tight">Flowstate Login</h1>
<p className="text-sm text-muted">Sign in to continue.</p>
</div>
{/* Error Notification Banner */}
{error && (
<div
className="p-3 text-xs font-medium text-destructive bg-destructive-bg border border-destructive-border rounded-lg text-center"
role="alert"
>
{error}
</div>
)}
{/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block space-y-2">
<span className="text-xs font-medium text-muted">E-Mail</span>
<input
name="email"
type="email"
placeholder="name@example.com"
defaultValue="test@flowstate.io"
required
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
/>
</label>
<label className="block space-y-2">
<span className="text-xs font-medium text-muted">Passwort</span>
<input
name="password"
type="password"
placeholder="••••••••"
defaultValue="password"
required
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:border-foreground"
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full py-2 text-sm font-medium rounded-lg bg-foreground text-background hover:opacity-70 transition-opacity disabled:opacity-20 cursor-pointer"
>
{loading ? "Logging in..." : "Sign In"}
</button>
</form>
{/* Registration Link Footer */}
<div className="text-center text-xs text-muted">
Don't have an account yet?{" "}
<Link href="/register/" className="text-foreground hover:underline">
Register
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View file

@ -3,7 +3,7 @@ export default function BrandLogo() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9"> <div className="w-9 h-9">
<img <img
src="logo.png" src="/logo.png"
alt="Flowstate Logo" alt="Flowstate Logo"
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />

View file

@ -1,4 +1,4 @@
import Brand from "./Brand"; import BrandLogo from "./BrandLogo";
import Image from "next/image"; import Image from "next/image";
export default function Header() { export default function Header() {
@ -6,7 +6,7 @@ export default function Header() {
<header className="h-20 border-b border-border backdrop-blur-md p-4 flex items-center md:justify-end justify-between sticky top-0 z-50"> <header className="h-20 border-b border-border backdrop-blur-md p-4 flex items-center md:justify-end justify-between sticky top-0 z-50">
{/* Brand Element */} {/* Brand Element */}
<div className="md:hidden flex items-center gap-3"> <div className="md:hidden flex items-center gap-3">
<Brand /> <BrandLogo />
</div> </div>
{/* User Profil */} {/* User Profil */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View file

@ -1,13 +1,20 @@
import Brand from "./Brand"; import BrandLogo from "./BrandLogo";
import Navbar from "./Navbar"; import Navbar from "./Navbar";
import SignOutButton from "./buttons/SignOutButton";
export default function Sidebar() { export default function Sidebar() {
return ( return (
<aside className="flex-col h-full w-64 p-6 select-none border-r border-border shrink-0"> <aside className="hidden md:flex flex-col justify-between h-full w-64 p-6 select-none border-r border-border shrink-0">
{/* Brand Element */} {/* Upper Section */}
<Brand /> <div className="flex flex-col">
{/* Navbar Element */} <BrandLogo />
<Navbar /> <Navbar />
</div>
{/* Lower Section */}
<div className="flex justify-center">
<SignOutButton />
</div>
</aside> </aside>
); );
} }

View file

@ -0,0 +1,21 @@
import { signOut } from "@/auth";
import { LogOut } from "lucide-react";
export default function SignOutButton() {
return (
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-muted hover:text-foreground transition-colors w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
<span>Sign out</span>
</button>
</form>
);
}

View file

@ -5,6 +5,11 @@
--color-foreground: hsl(0 0% 100%); --color-foreground: hsl(0 0% 100%);
--color-muted: hsl(0 0% 63%); --color-muted: hsl(0 0% 63%);
--color-border: hsl(0 0% 15%); --color-border: hsl(0 0% 15%);
--color-card: hsl(0 0% 8%);
--color-destructive: hsl(0 84% 60%);
--color-destructive-bg: hsl(0 84% 10% / 0.4);
--color-destructive-border: hsl(0 84% 20% / 0.5);
--font-sans: --font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,

View file

@ -1,20 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Sidebar from "./components/layout/Sidebar";
import Header from "./components/layout/Header";
import Navbar from "./components/layout/Navbar";
/**
* Global page metadata
*/
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Flowstate - Modern Kanban", title: "Flowstate - Modern Kanban",
description: "Dein High-Performance Workflow Dashboard", description: "Dein High-Performance Workflow Dashboard",
}; };
/**
* Root application layout providing global shell UI
*/
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@ -22,24 +13,8 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="de" className="dark h-full"> <html lang="de" className="dark h-full">
<body className="flex h-dvh overflow-hidden"> <body className="flex h-dvh overflow-hidden bg-background text-foreground">
{/* Sidebar (Desktop only) */}
<div className="hidden md:flex ">
<Sidebar />
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden pb-16 md:pb-0">
<Header />
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
{children} {children}
</main>
{/* Mobile Navbar (Mobile only) */}
<div className="md:hidden flex">
<Navbar />
</div>
</div>
</body> </body>
</html> </html>
); );

View file

@ -1,6 +1,35 @@
/** import { auth } from "@/auth";
* Landing page import { redirect } from "next/navigation";
*/ import Link from "next/link";
export default function Home() {
return <div className="space-y-6">Home</div>; export default async function WelcomePage() {
const session = typeof auth === "function" ? await auth() : null;
if (session) {
redirect("/board");
}
return (
<div className="flex h-dvh w-full flex-col items-center justify-center p-6 text-center">
<div className="max-w-md space-y-6">
<h1 className="text-4xl font-extrabold tracking-tight">Flowstate</h1>
<p className="text-muted">Welcome Page</p>
<div className="flex items-center justify-center gap-4">
<Link
href="/login/"
className="px-4 py-2 text-sm font-medium rounded-lg bg-foreground text-background hover:opacity-70 transition-opacity"
>
Sign In
</Link>
{/* TODO */}
<Link
href="/register/"
className="px-4 py-2 text-sm font-medium rounded-lg border border-border hover:opacity-70 transition-opacity"
>
Register
</Link>
</div>
</div>
</div>
);
} }

26
auth.ts Normal file
View file

@ -0,0 +1,26 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (
credentials?.email === "test@flowstate.io" &&
credentials?.password === "password"
) {
return { id: "1", name: "Charlotte W.", email: "test@flowstate.io" };
}
return null;
},
}),
],
pages: {
signIn: "/login",
},
});

103
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"next": "16.2.12", "next": "16.2.12",
"next-auth": "^5.0.0-beta.32",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
@ -37,6 +38,35 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@auth/core": {
"version": "0.41.3",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz",
"integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7.0.7 || ^8.0.5"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@ -1345,6 +1375,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -4667,6 +4706,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.7",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz",
"integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5300,6 +5348,33 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "5.0.0-beta.32",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz",
"integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7 || ^8.0.5",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
@ -5329,6 +5404,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5618,6 +5702,25 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"next": "16.2.12", "next": "16.2.12",
"next-auth": "^5.0.0-beta.32",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },