basic login implementation at localhost:3000//login
This commit is contained in:
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,6 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
|
<file type="web" url="file://$PROJECT_DIR$/apps/workspace-api" />
|
||||||
|
</component>
|
||||||
<component name="ProjectRootManager">
|
<component name="ProjectRootManager">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
BIN
apps/.DS_Store
vendored
Normal file
BIN
apps/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -7,7 +7,7 @@ This document describes the architecture of the VOYAGE public blog,
|
|||||||
including folder structure, routing, data flow, and rendering logic.
|
including folder structure, routing, data flow, and rendering logic.
|
||||||
|
|
||||||
A new developer should be able to understand how blog posts are loaded,
|
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
|
High-Level Overview
|
||||||
@@ -49,11 +49,13 @@ apps/public-web/
|
|||||||
│ │ ├── page.tsx → Homepage
|
│ │ ├── page.tsx → Homepage
|
||||||
│ │ ├── about/
|
│ │ ├── about/
|
||||||
│ │ │ └── page.tsx → Static About page
|
│ │ │ └── page.tsx → Static About page
|
||||||
│ │ └── blog/
|
│ │ ├── blog/
|
||||||
│ │ ├── layout.tsx → Blog-specific layout (optional)
|
│ │ │ ├── layout.tsx → Blog-specific layout (optional)
|
||||||
│ │ ├── page.tsx → Blog index (list of posts)
|
│ │ │ ├── page.tsx → Blog index (list of posts)
|
||||||
│ │ └── [slug]/
|
│ │ │ └── [slug]/
|
||||||
│ │ └── page.tsx → Dynamic blog post page
|
│ │ │ └── page.tsx → Dynamic blog post page
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ └── page.tsx → Admin-only page (protected)
|
||||||
│ │
|
│ │
|
||||||
│ ├── global.css → Global styles
|
│ ├── global.css → Global styles
|
||||||
│ └── layout.tsx → Root layout
|
│ └── layout.tsx → Root layout
|
||||||
@@ -93,6 +95,9 @@ Static routes:
|
|||||||
Dynamic routes:
|
Dynamic routes:
|
||||||
- /blog/[slug] → app/(site)/blog/[slug]/page.tsx
|
- /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
|
The `[slug]` directory defines a dynamic route parameter
|
||||||
that is passed to the page component as `params.slug`.
|
that is passed to the page component as `params.slug`.
|
||||||
|
|
||||||
@@ -169,35 +174,110 @@ public/blog/visuals/
|
|||||||
- Referenced in MDX or page components
|
- 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
|
Why This Architecture
|
||||||
---------------------
|
---------------------
|
||||||
- Clear separation of concerns
|
- Single source of truth for authentication (Spring)
|
||||||
- File-based routing (no manual routing config)
|
- No duplicate auth logic in frontend
|
||||||
- Content-driven architecture
|
- Clean separation:
|
||||||
- Easy to add new posts
|
- Public content → no auth
|
||||||
- Scales well for future features:
|
- Admin tools → backend session
|
||||||
- Tags
|
- Works with SSR and Server Components
|
||||||
- Categories
|
- Production-ready pattern for multi-app monorepos
|
||||||
- RSS feeds
|
|
||||||
- Pagination
|
|
||||||
- Search
|
|
||||||
|
|
||||||
|
|
||||||
How to Add a New Blog Post
|
How to Extend (Future)
|
||||||
-------------------------
|
----------------------
|
||||||
1. Create a new MDX file in:
|
- Admin editor UI
|
||||||
content/posts/
|
- Draft / preview mode
|
||||||
|
- Role-based admin features
|
||||||
2. Use a unique filename:
|
- CMS integration
|
||||||
YYYY-MM-DD-your-title.mdx
|
- Protected preview links
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
Current Status
|
Current Status
|
||||||
@@ -206,4 +286,5 @@ Current Status
|
|||||||
- Dynamic blog slugs working
|
- Dynamic blog slugs working
|
||||||
- Shared public layout integrated
|
- Shared public layout integrated
|
||||||
- MDX content loading stable
|
- MDX content loading stable
|
||||||
|
- Admin auth flow stable and tested
|
||||||
- Ready for styling, animations, and feature extensions
|
- Ready for styling, animations, and feature extensions
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TopBar, BackLink } from "../../../components/shell/TopBar";
|
import { TopBar, BackLink } from "../../../components/shell/TopBar";
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<TopBar title="Info" left={<BackLink href="/" />} />
|
<TopBar title="Info" left={<BackLink href="/" />} />
|
||||||
|
|||||||
38
apps/public-web/app/(site)/admin/page.tsx
Normal file
38
apps/public-web/app/(site)/admin/page.tsx
Normal 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: 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 (
|
||||||
|
<main>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<pre>{JSON.stringify(me, null, 2)}</pre>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/public-web/app/(site)/login/page.tsx
Normal file
7
apps/public-web/app/(site)/login/page.tsx
Normal 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}`);
|
||||||
|
}
|
||||||
4
apps/public-web/cookies.txt
Normal file
4
apps/public-web/cookies.txt
Normal 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.
|
||||||
|
|
||||||
6
apps/workspace-api/data/voyage-db.lock.db
Normal file
6
apps/workspace-api/data/voyage-db.lock.db
Normal 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
|
||||||
Binary file not shown.
@@ -2,10 +2,12 @@ package com.voyage.workspace.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -14,7 +16,7 @@ public class SecurityConfig {
|
|||||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
|
||||||
http.authorizeHttpRequests(auth -> auth
|
http.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/health", "/error").permitAll()
|
.requestMatchers("/health", "/error", "/login", "/login-redirect").permitAll()
|
||||||
.requestMatchers("/h2-console/**").permitAll()
|
.requestMatchers("/h2-console/**").permitAll()
|
||||||
|
|
||||||
// Admin-only user management
|
// Admin-only user management
|
||||||
@@ -27,8 +29,25 @@ public class SecurityConfig {
|
|||||||
.anyRequest().authenticated()
|
.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
|
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
|
http.logout(logout -> logout
|
||||||
|
|||||||
@@ -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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
data/voyage-db.mv.db
Normal file
BIN
data/voyage-db.mv.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user