basic login implementation at localhost:3000//login

This commit is contained in:
Domonkos
2026-01-27 18:32:29 +01:00
parent 6dbacca017
commit 086e5a867d
16 changed files with 234 additions and 34 deletions

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -7,7 +7,7 @@ This document describes the architecture of the VOYAGE public blog,
including folder structure, routing, data flow, and rendering logic.
A new developer should be able to understand how blog posts are loaded,
renderouten funktionieren, and where to extend the system after reading this file.
render routes work, and where to extend the system after reading this file.
High-Level Overview
@@ -49,11 +49,13 @@ apps/public-web/
│ │ ├── page.tsx → Homepage
│ │ ├── about/
│ │ │ └── page.tsx → Static About page
│ │ ── blog/
│ │ ├── layout.tsx → Blog-specific layout (optional)
│ │ ├── page.tsx → Blog index (list of posts)
│ │ └── [slug]/
│ │ └── page.tsx → Dynamic blog post page
│ │ ── blog/
│ │ ├── layout.tsx → Blog-specific layout (optional)
│ │ ├── page.tsx → Blog index (list of posts)
│ │ └── [slug]/
│ │ └── page.tsx → Dynamic blog post page
│ │ └── admin/
│ │ └── page.tsx → Admin-only page (protected)
│ │
│ ├── global.css → Global styles
│ └── layout.tsx → Root layout
@@ -93,6 +95,9 @@ Static routes:
Dynamic routes:
- /blog/[slug] → app/(site)/blog/[slug]/page.tsx
Admin route:
- /admin → app/(site)/admin/page.tsx (protected)
The `[slug]` directory defines a dynamic route parameter
that is passed to the page component as `params.slug`.
@@ -169,35 +174,110 @@ public/blog/visuals/
- Referenced in MDX or page components
Admin Authentication & Security
--------------------------------
The blog includes an **admin-only section** used for internal tools
(e.g. editor preview, drafts, future CMS features).
Authentication is **not handled by Next.js**, but delegated to the
existing Spring Boot backend (`workspace-api`).
Key principles:
- Public blog remains fully accessible without login
- Admin routes require a valid backend session
- Session-based authentication (JSESSIONID)
- No JWT, no duplicate auth system
Admin Route Protection (Frontend)
---------------------------------
Route:
- /admin → app/(site)/admin/page.tsx
Protection strategy:
- Implemented as an **async Server Component guard**
- On each request:
- Calls backend endpoint `/api/me`
- Forwards cookies manually
- If response is 401 → redirect to /login
- If response is 200 → render admin UI
Important detail:
- Server-side fetch must forward cookies explicitly
- credentials: "include" is NOT sufficient in Server Components
Login Flow (End-to-End)
----------------------
1. User navigates to:
http://localhost:3000/admin
2. Admin page fetches:
GET http://localhost:8080/api/me
3. If not authenticated:
- Backend returns 401
- Next.js redirects to /login (frontend route)
4. Frontend /login page redirects to backend:
GET http://localhost:8080/login-redirect?redirect=http://localhost:3000/admin
5. Backend:
- Stores redirect target in session
- Redirects to /login (without query params)
6. Spring Security default login page is shown
7. User submits credentials
8. On successful login:
- Custom successHandler reads redirect from session
- User is redirected to:
http://localhost:3000/admin
9. Admin page loads successfully
Backend Endpoints Involved
-------------------------
/api/me
- Returns current authenticated user
- 200 → logged in
- 401 → not authenticated
- Never redirects (API-safe)
/login
- Spring Security default login page
- HTML form-based login
/login-redirect
- Helper endpoint
- Stores redirect target in session
- Avoids unsupported query params on /login
Why This Architecture
---------------------
- Clear separation of concerns
- File-based routing (no manual routing config)
- Content-driven architecture
- Easy to add new posts
- Scales well for future features:
- Tags
- Categories
- RSS feeds
- Pagination
- Search
- Single source of truth for authentication (Spring)
- No duplicate auth logic in frontend
- Clean separation:
- Public content → no auth
- Admin tools → backend session
- Works with SSR and Server Components
- Production-ready pattern for multi-app monorepos
How to Add a New Blog Post
-------------------------
1. Create a new MDX file in:
content/posts/
2. Use a unique filename:
YYYY-MM-DD-your-title.mdx
3. Add frontmatter metadata (title, date, etc.)
4. Optionally add images to:
public/blog/visuals/
5. The post is automatically available at:
/blog/your-title
How to Extend (Future)
----------------------
- Admin editor UI
- Draft / preview mode
- Role-based admin features
- CMS integration
- Protected preview links
Current Status
@@ -206,4 +286,5 @@ Current Status
- Dynamic blog slugs working
- Shared public layout integrated
- MDX content loading stable
- Admin auth flow stable and tested
- Ready for styling, animations, and feature extensions

View File

@@ -1,6 +1,7 @@
import { TopBar, BackLink } from "../../../components/shell/TopBar";
export default function AboutPage() {
// @ts-ignore
return (
<div>
<TopBar title="Info" left={<BackLink href="/" />} />

View File

@@ -0,0 +1,38 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
export default async function AdminPage() {
const cookieHeader = (await cookies())
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join("; ");
const res = await fetch(`${process.env.BACKEND_URL}/api/me`, {
headers: { cookie: cookieHeader },
cache: "no-store",
redirect: "manual", // IMPORTANT: dont follow to /login
});
// Spring might answer 302 to /login when unauthenticated
if (res.status === 401 || res.status === 302) redirect("/login");
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to load session: ${res.status} ${text.slice(0, 200)}`);
}
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
// we got HTML (login page) or something else
redirect("/login");
}
const me = await res.json();
return (
<main>
<h1>Admin</h1>
<pre>{JSON.stringify(me, null, 2)}</pre>
</main>
);
}

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
export default function LoginPage() {
const backend = process.env.BACKEND_URL!;
const returnTo = encodeURIComponent("http://localhost:3000/admin");
redirect(`${backend}/login-redirect?redirect=${returnTo}`);
}

View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

@@ -0,0 +1,6 @@
#FileLock
#Tue Jan 27 18:25:29 CET 2026
hostName=macbook-air-von-melika.fritz.box
id=19c007d24ac0d06a483d34679bbb29160d6d33ed895
method=file
server=192.168.178.68\:58736

View File

@@ -2,10 +2,12 @@ package com.voyage.workspace.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
@Configuration
public class SecurityConfig {
@@ -14,7 +16,7 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/error").permitAll()
.requestMatchers("/health", "/error", "/login", "/login-redirect").permitAll()
.requestMatchers("/h2-console/**").permitAll()
// Admin-only user management
@@ -27,8 +29,25 @@ public class SecurityConfig {
.anyRequest().authenticated()
);
// IMPORTANT: For API calls, return 401 instead of redirecting to /login (HTML)
http.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
request -> request.getRequestURI() != null && request.getRequestURI().startsWith("/api/")
)
);
http.formLogin(form -> form
.defaultSuccessUrl("http://localhost:3000/", true)
.successHandler((request, response, authentication) -> {
String target = request.getParameter("redirect");
if (target == null) {
Object saved = request.getSession().getAttribute("LOGIN_REDIRECT");
if (saved != null) target = saved.toString();
}
boolean allowed = target != null && target.startsWith("http://localhost:3000/");
response.sendRedirect(allowed ? target : "http://localhost:3000/admin");
})
);
http.logout(logout -> logout

View File

@@ -1,4 +1,4 @@
package com.voyage.workspace;
package com.voyage.workspace.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

View File

@@ -0,0 +1,16 @@
package com.voyage.workspace.controller;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class LoginRedirectController {
@GetMapping("/login-redirect")
public String loginRedirect(@RequestParam("redirect") String redirect, HttpSession session) {
session.setAttribute("LOGIN_REDIRECT", redirect);
return "redirect:/login";
}
}

View File

@@ -0,0 +1,19 @@
package com.voyage.workspace.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class MeController {
@GetMapping("/api/me")
public Map<String, Object> me(Authentication auth) {
return Map.of(
"name", auth.getName(),
"authorities", auth.getAuthorities()
);
}
}