diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..e57816e Binary files /dev/null and b/.DS_Store differ diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index ade1ecd..79e7899 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,9 @@ + + + diff --git a/apps/.DS_Store b/apps/.DS_Store new file mode 100644 index 0000000..f788cef Binary files /dev/null and b/apps/.DS_Store differ diff --git a/apps/public-web/ARCHITECTURE.md b/apps/public-web/ARCHITECTURE.md index 64d6054..e17715d 100644 --- a/apps/public-web/ARCHITECTURE.md +++ b/apps/public-web/ARCHITECTURE.md @@ -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 \ No newline at end of file diff --git a/apps/public-web/app/(site)/about/page.tsx b/apps/public-web/app/(site)/about/page.tsx index 6487fc9..d9a8a24 100644 --- a/apps/public-web/app/(site)/about/page.tsx +++ b/apps/public-web/app/(site)/about/page.tsx @@ -1,6 +1,7 @@ import { TopBar, BackLink } from "../../../components/shell/TopBar"; export default function AboutPage() { + // @ts-ignore return (
} /> diff --git a/apps/public-web/app/(site)/admin/page.tsx b/apps/public-web/app/(site)/admin/page.tsx new file mode 100644 index 0000000..cc9485e --- /dev/null +++ b/apps/public-web/app/(site)/admin/page.tsx @@ -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: don’t 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 ( +
+

Admin

+
{JSON.stringify(me, null, 2)}
+
+ ); +} \ No newline at end of file diff --git a/apps/public-web/app/(site)/login/page.tsx b/apps/public-web/app/(site)/login/page.tsx new file mode 100644 index 0000000..d27eef4 --- /dev/null +++ b/apps/public-web/app/(site)/login/page.tsx @@ -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}`); +} \ No newline at end of file diff --git a/apps/public-web/cookies.txt b/apps/public-web/cookies.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/apps/public-web/cookies.txt @@ -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. + diff --git a/apps/workspace-api/data/voyage-db.lock.db b/apps/workspace-api/data/voyage-db.lock.db new file mode 100644 index 0000000..25537e8 --- /dev/null +++ b/apps/workspace-api/data/voyage-db.lock.db @@ -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 diff --git a/apps/workspace-api/data/voyage-db.mv.db b/apps/workspace-api/data/voyage-db.mv.db index 3839747..50493b2 100644 Binary files a/apps/workspace-api/data/voyage-db.mv.db and b/apps/workspace-api/data/voyage-db.mv.db differ diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java b/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java index dc4cbda..4f531a6 100644 --- a/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java @@ -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 diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/HealthController.java b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/HealthController.java similarity index 86% rename from apps/workspace-api/src/main/java/com/voyage/workspace/HealthController.java rename to apps/workspace-api/src/main/java/com/voyage/workspace/controller/HealthController.java index b2d486e..487a1cf 100644 --- a/apps/workspace-api/src/main/java/com/voyage/workspace/HealthController.java +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/HealthController.java @@ -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; diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/controller/LoginRedirectController.java b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/LoginRedirectController.java new file mode 100644 index 0000000..a104862 --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/LoginRedirectController.java @@ -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"; + } +} \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/controller/MeController.java b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/MeController.java new file mode 100644 index 0000000..5d33aa0 --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/controller/MeController.java @@ -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 me(Authentication auth) { + return Map.of( + "name", auth.getName(), + "authorities", auth.getAuthorities() + ); + } +} \ No newline at end of file diff --git a/data/voyage-db.mv.db b/data/voyage-db.mv.db new file mode 100644 index 0000000..201fa75 Binary files /dev/null and b/data/voyage-db.mv.db differ