feat: rewrite frontend from Go templates to Bun + TSX

Replace Go HTML template rendering with a Bun + TSX build-time static
site generator. Go backend becomes API-only for auth.

Frontend:
- Custom JSX-to-HTML-string factory (zero dependencies)
- TSX components for Header, Footer, index page, login page
- Client-side login.ts handles tab switching and fetch()-based auth
- Bun bundler compiles client JS, build.ts renders pages to dist/

Backend:
- Auth handlers return JSON (POST /api/login, POST /api/register)
- Login page served as static HTML from dist/
- Static assets served from /assets/ (public)
- Auth middleware unchanged (cookie check, redirect to /login)
- Removed template parsing and renderPage

Dockerfile:
- 3-stage build: Bun frontend -> Go backend -> alpine runtime
- Frontend dist copied to /app/dist in final image

Removed: templates/, static/css/ (replaced by frontend/)
This commit is contained in:
m
2026-04-14 16:50:27 +02:00
parent b75d91fbd7
commit 40a9c927fb
20 changed files with 505 additions and 323 deletions

View File

@@ -4,3 +4,5 @@
.m .m
*.md *.md
!README.md !README.md
frontend/node_modules
frontend/dist

4
.gitignore vendored
View File

@@ -3,6 +3,10 @@
/patholo /patholo
*.exe *.exe
# Frontend
frontend/node_modules/
frontend/dist/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/

View File

@@ -1,5 +1,9 @@
FROM golang:1.23-alpine AS build FROM oven/bun:1 AS frontend
WORKDIR /app/frontend
COPY frontend/ .
RUN bun install && bun run build
FROM golang:1.23-alpine AS backend
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod ./
RUN go mod download RUN go mod download
@@ -9,9 +13,7 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /patholo ./cmd/server
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
WORKDIR /app WORKDIR /app
COPY --from=build /patholo /app/patholo COPY --from=backend /patholo /app/patholo
COPY templates/ /app/templates/ COPY --from=frontend /app/frontend/dist /app/dist
COPY static/ /app/static/
EXPOSE 8080 EXPOSE 8080
CMD ["/app/patholo"] CMD ["/app/patholo"]

42
frontend/build.ts Normal file
View File

@@ -0,0 +1,42 @@
import { mkdir, cp, rm } from "fs/promises";
import { join } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
const DIST = join(import.meta.dir, "dist");
async function build() {
// Clean dist/
await rm(DIST, { recursive: true, force: true });
await mkdir(join(DIST, "assets"), { recursive: true });
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [join(import.meta.dir, "src/client/login.ts")],
outdir: join(DIST, "assets"),
naming: "[name].js",
minify: true,
});
if (!result.success) {
console.error("JS build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
// Copy CSS
await cp(
join(import.meta.dir, "src/styles/global.css"),
join(DIST, "assets/global.css"),
);
// Render HTML pages
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
console.log("Build complete \u2192 dist/");
}
build();

21
frontend/bun.lock Normal file
View File

@@ -0,0 +1,21 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "patholo-frontend",
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}

2
frontend/bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[install]
peer = false

10
frontend/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "patholo-frontend",
"private": true,
"scripts": {
"build": "bun run build.ts"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@@ -0,0 +1,108 @@
document.addEventListener("DOMContentLoaded", () => {
const tabs = document.querySelectorAll<HTMLButtonElement>(".login-tab");
const loginForm = document.getElementById("login-form") as HTMLFormElement;
const registerForm = document.getElementById("register-form") as HTMLFormElement;
// Tab switching
tabs.forEach((btn) => {
btn.addEventListener("click", () => {
tabs.forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const isLogin = btn.dataset.tab === "login";
loginForm.style.display = isLogin ? "" : "none";
registerForm.style.display = isLogin ? "none" : "";
clearMessages();
});
});
// Login form
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearMessages();
const data = new FormData(loginForm);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: data.get("email"),
password: data.get("password"),
}),
});
const json = await res.json();
if (res.ok) {
window.location.href = "/";
} else {
showError(json.error);
}
} catch {
showError("Verbindungsfehler. Bitte versuchen Sie es erneut.");
}
});
// Register form
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearMessages();
const data = new FormData(registerForm);
const password = data.get("password") as string;
const confirm = data.get("confirm") as string;
if (password !== confirm) {
showError("Passw\u00F6rter stimmen nicht \u00FCberein.");
return;
}
if (password.length < 8) {
showError("Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: data.get("email"),
password: password,
}),
});
const json = await res.json();
if (res.ok) {
if (json.redirect) {
window.location.href = json.redirect;
} else {
showSuccess(json.message || "Account erstellt. Bitte melden Sie sich an.");
tabs[0].click();
}
} else {
showError(json.error);
}
} catch {
showError("Verbindungsfehler. Bitte versuchen Sie es erneut.");
}
});
});
function clearMessages() {
document.querySelectorAll(".login-error, .login-success").forEach((el) => el.remove());
}
function showError(msg: string) {
showMessage(msg, "login-error");
}
function showSuccess(msg: string) {
showMessage(msg, "login-success");
}
function showMessage(msg: string, cls: string) {
clearMessages();
const div = document.createElement("div");
div.className = cls;
div.textContent = msg;
const tabs = document.querySelector(".login-tabs");
if (tabs) {
tabs.after(div);
}
}

View File

@@ -0,0 +1,11 @@
import { h } from "../jsx";
export function Footer(): string {
return (
<footer className="footer">
<div className="container">
<p>{"\u00A9 2026 patholo \u2014 Internal use only. Hogan Lovells Patent Practice."}</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,30 @@
import { h } from "../jsx";
interface HeaderProps {
showLogout?: boolean;
}
export function Header({ showLogout }: HeaderProps): string {
return (
<header className="header">
<div className="container">
<nav className="nav">
<a href="/" className="logo">
<span className="logo-mark">p</span>
<span className="logo-text">patholo</span>
</a>
{showLogout && (
<div className="nav-right">
<a href="/logout" className="nav-logout">Abmelden</a>
<div className="nav-lang">
<span className="lang-active">DE</span>
<span className="lang-sep">/</span>
<span className="lang-inactive">EN</span>
</div>
</div>
)}
</nav>
</div>
</header>
);
}

74
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>';
const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
export function renderIndex(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>patholo Patent Knowledge for Hogan Lovells</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body>
<Header showLogout={true} />
<main>
<section className="hero">
<div className="container">
<h1>Patent Knowledge<br /><span className="hero-accent">f&uuml;r Hogan Lovells</span></h1>
<p className="hero-sub">
Leitf&auml;den, Vorlagen und Dokumente f&uuml;r das HL Patent-Team.
<br />
<span className="hero-en">Guides, templates, and documents for the HL patent team.</span>
</p>
</div>
</section>
<section className="sections">
<div className="container">
<div className="grid">
<div className="card">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BOOK }} />
<h2>Leitf&auml;den <span className="card-en">Guides</span></h2>
<p>Praxisleitf&auml;den zu Verfahren vor dem EPA, BPatG und UPC. Schritt-f&uuml;r-Schritt-Anleitungen f&uuml;r typische Workflows.</p>
</div>
<div className="card">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FILE }} />
<h2>Vorlagen <span className="card-en">Templates</span></h2>
<p>Standardisierte Vorlagen f&uuml;r Schrifts&auml;tze, Korrespondenz und interne Dokumente. HL Patents Style Guide.</p>
</div>
<div className="card">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
<h2>Dokumente <span className="card-en">Documents</span></h2>
<p>Referenzmaterialien, Checklisten und Arbeitshilfen f&uuml;r den Praxisalltag im Patentrecht.</p>
</div>
</div>
</div>
</section>
<section className="offices">
<div className="container">
<h3>Standorte <span className="card-en">Offices</span></h3>
<div className="office-list">
<span>M&uuml;nchen</span>
<span>D&uuml;sseldorf</span>
<span>Amsterdam</span>
<span>London</span>
</div>
</div>
</section>
</main>
<Footer />
</body>
</html>
);
}

62
frontend/src/jsx.ts Normal file
View File

@@ -0,0 +1,62 @@
const VOID_ELEMENTS = new Set([
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
]);
const ATTR_MAP: Record<string, string> = {
className: "class",
htmlFor: "for",
};
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function flatten(children: any[]): string {
return children
.flat(Infinity)
.filter((c) => c != null && c !== false && c !== true)
.map((c) => String(c))
.join("");
}
export function h(
tag: string | ((props: any) => string),
props: Record<string, any> | null,
...children: any[]
): string {
if (typeof tag === "function") {
return tag({ ...props, children: children.length === 1 ? children[0] : children });
}
let attrs = "";
let innerHTML = "";
if (props) {
for (const [key, value] of Object.entries(props)) {
if (key === "children") continue;
if (key === "dangerouslySetInnerHTML") {
innerHTML = value.__html;
continue;
}
if (value == null || value === false) continue;
const name = ATTR_MAP[key] || key;
if (value === true) {
attrs += ` ${name}`;
} else {
attrs += ` ${name}="${escapeAttr(String(value))}"`;
}
}
}
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrs}>`;
}
const content = innerHTML || flatten(children);
return `<${tag}${attrs}>${content}</${tag}>`;
}
export function Fragment({ children }: { children: any }): string {
return Array.isArray(children) ? flatten(children) : String(children ?? "");
}

52
frontend/src/login.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
export function renderLogin(loginJs: string): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anmelden patholo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body>
<Header />
<main className="login-main">
<div className="login-card">
<div className="login-tabs">
<button className="login-tab active" data-tab="login">Anmelden</button>
<button className="login-tab" data-tab="register">Registrieren</button>
</div>
<form className="login-form" id="login-form">
<label htmlFor="login-email" className="login-label">E-Mail</label>
<input type="email" id="login-email" name="email" placeholder="name@hoganlovells.com" required autofocus className="login-input" />
<label htmlFor="login-password" className="login-label">Passwort</label>
<input type="password" id="login-password" name="password" placeholder="Passwort" required className="login-input" />
<button type="submit" className="login-button">Anmelden</button>
</form>
<form className="login-form" id="register-form" style="display:none">
<label htmlFor="reg-email" className="login-label">E-Mail</label>
<input type="email" id="reg-email" name="email" placeholder="name@hoganlovells.com" required className="login-input" />
<label htmlFor="reg-password" className="login-label">Passwort</label>
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" className="login-input" />
<label htmlFor="reg-confirm" className="login-label">Passwort bestätigen</label>
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" className="login-input" />
<button type="submit" className="login-button">Registrieren</button>
</form>
<p className="login-hint">{"Nur f\u00FCr @hoganlovells.com Adressen."}</p>
</div>
</main>
<Footer />
<script src={`/assets/${loginJs}`}></script>
</body>
</html>
);
}

View File

@@ -50,7 +50,7 @@ main {
padding: 0 1.5rem; padding: 0 1.5rem;
} }
/* ─── Header ─── */ /* --- Header --- */
.header { .header {
background: var(--color-surface); background: var(--color-surface);
@@ -117,7 +117,7 @@ main {
margin: 0 0.25rem; margin: 0 0.25rem;
} }
/* ─── Hero ─── */ /* --- Hero --- */
.hero { .hero {
background: var(--color-hero-bg); background: var(--color-hero-bg);
@@ -151,7 +151,7 @@ main {
font-size: 0.95rem; font-size: 0.95rem;
} }
/* ─── Card Grid ─── */ /* --- Card Grid --- */
.sections { .sections {
padding: 4rem 0; padding: 4rem 0;
@@ -207,7 +207,7 @@ main {
line-height: 1.6; line-height: 1.6;
} }
/* ─── Offices ─── */ /* --- Offices --- */
.offices { .offices {
padding: 0 0 4rem; padding: 0 0 4rem;
@@ -252,7 +252,7 @@ main {
background: var(--color-accent); background: var(--color-accent);
} }
/* ─── Footer ─── */ /* --- Footer --- */
.footer { .footer {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
@@ -266,7 +266,7 @@ main {
text-align: center; text-align: center;
} }
/* ─── Login ─── */ /* --- Login --- */
.login-main { .login-main {
flex: 1; flex: 1;
@@ -401,7 +401,7 @@ main {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
/* ─── Responsive ─── */ /* --- Responsive --- */
@media (max-width: 768px) { @media (max-width: 768px) {
.hero { .hero {

14
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*", "build.ts"]
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
"strings" "strings"
@@ -9,24 +10,6 @@ import (
"mgit.msbls.de/m/patholo/internal/auth" "mgit.msbls.de/m/patholo/internal/auth"
) )
type loginData struct {
Mode string
Error string
Success string
Email string
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleLoginPage(w, r)
case http.MethodPost:
handleLoginSubmit(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleLoginPage(w http.ResponseWriter, r *http.Request) { func handleLoginPage(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" { if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) { if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) {
@@ -34,130 +17,89 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
http.ServeFile(w, r, "dist/login.html")
data := loginData{
Mode: r.URL.Query().Get("mode"),
Error: r.URL.Query().Get("error"),
}
if data.Mode == "" {
data.Mode = "login"
}
renderPage(w, "login.html", data)
} }
func handleLoginSubmit(w http.ResponseWriter, r *http.Request) { func handleAPILogin(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email")) var req struct {
password := r.FormValue("password") Email string `json:"email"`
Password string `json:"password"`
if email == "" || password == "" { }
renderPage(w, "login.html", loginData{ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Mode: "login", writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
Error: "Bitte E-Mail und Passwort eingeben.",
Email: email,
})
return return
} }
if !isHoganLovellsEmail(email) { req.Email = strings.TrimSpace(req.Email)
renderPage(w, "login.html", loginData{ if req.Email == "" || req.Password == "" {
Mode: "login", writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte E-Mail und Passwort eingeben."})
Error: "Zugang nur für @hoganlovells.com E-Mail-Adressen.",
Email: email,
})
return return
} }
tokens, err := authClient.SignIn(email, password) if !isHoganLovellsEmail(req.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für @hoganlovells.com E-Mail-Adressen."})
return
}
tokens, err := authClient.SignIn(req.Email, req.Password)
if err != nil { if err != nil {
log.Printf("sign in failed for %s: %v", email, err) log.Printf("sign in failed for %s: %v", req.Email, err)
errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
if strings.Contains(err.Error(), "Invalid login credentials") { if strings.Contains(err.Error(), "Invalid login credentials") {
errMsg = "Ungültige E-Mail-Adresse oder Passwort." errMsg = "Ungültige E-Mail-Adresse oder Passwort."
} }
renderPage(w, "login.html", loginData{ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": errMsg})
Mode: "login",
Error: errMsg,
Email: email,
})
return return
} }
auth.SetAuthCookies(w, r, tokens) auth.SetAuthCookies(w, r, tokens)
http.Redirect(w, r, "/", http.StatusFound) writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
} }
func handleRegister(w http.ResponseWriter, r *http.Request) { func handleAPIRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { var req struct {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
return return
} }
email := strings.TrimSpace(r.FormValue("email")) req.Email = strings.TrimSpace(req.Email)
password := r.FormValue("password") if req.Email == "" || req.Password == "" {
confirm := r.FormValue("confirm") writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte alle Felder ausfüllen."})
if email == "" || password == "" {
renderPage(w, "login.html", loginData{
Mode: "register",
Error: "Bitte alle Felder ausfüllen.",
Email: email,
})
return return
} }
if password != confirm { if len(req.Password) < 8 {
renderPage(w, "login.html", loginData{ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Passwort muss mindestens 8 Zeichen lang sein."})
Mode: "register",
Error: "Passwörter stimmen nicht überein.",
Email: email,
})
return return
} }
if len(password) < 8 { if !isHoganLovellsEmail(req.Email) {
renderPage(w, "login.html", loginData{ writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für @hoganlovells.com E-Mail-Adressen."})
Mode: "register",
Error: "Passwort muss mindestens 8 Zeichen lang sein.",
Email: email,
})
return return
} }
if !isHoganLovellsEmail(email) { tokens, err := authClient.SignUp(req.Email, req.Password)
renderPage(w, "login.html", loginData{
Mode: "register",
Error: "Registrierung nur für @hoganlovells.com E-Mail-Adressen.",
Email: email,
})
return
}
tokens, err := authClient.SignUp(email, password)
if err != nil { if err != nil {
log.Printf("sign up failed for %s: %v", email, err) log.Printf("sign up failed for %s: %v", req.Email, err)
errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut." errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") { if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") {
errMsg = "Ein Account mit dieser E-Mail existiert bereits." errMsg = "Ein Account mit dieser E-Mail existiert bereits."
} }
renderPage(w, "login.html", loginData{ writeJSON(w, http.StatusBadRequest, map[string]string{"error": errMsg})
Mode: "register",
Error: errMsg,
Email: email,
})
return return
} }
if tokens != nil { if tokens != nil {
auth.SetAuthCookies(w, r, tokens) auth.SetAuthCookies(w, r, tokens)
http.Redirect(w, r, "/", http.StatusFound) writeJSON(w, http.StatusOK, map[string]string{"redirect": "/"})
return return
} }
renderPage(w, "login.html", loginData{ writeJSON(w, http.StatusOK, map[string]string{"message": "Account erstellt. Bitte melden Sie sich an."})
Mode: "login",
Success: "Account erstellt. Bitte melden Sie sich an.",
Email: email,
})
} }
func handleLogout(w http.ResponseWriter, r *http.Request) { func handleLogout(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,65 +1,40 @@
package handlers package handlers
import ( import (
"html/template" "encoding/json"
"log"
"net/http" "net/http"
"path/filepath"
"mgit.msbls.de/m/patholo/internal/auth" "mgit.msbls.de/m/patholo/internal/auth"
) )
var ( var authClient *auth.Client
templates map[string]*template.Template
authClient *auth.Client
)
func Register(mux *http.ServeMux, client *auth.Client) { func Register(mux *http.ServeMux, client *auth.Client) {
authClient = client authClient = client
// Parse each page template separately so "content" blocks don't collide // API endpoints (JSON, public)
templates = make(map[string]*template.Template) mux.HandleFunc("POST /api/login", handleAPILogin)
for _, page := range []string{"index.html", "login.html"} { mux.HandleFunc("POST /api/register", handleAPIRegister)
t, err := template.ParseFiles(
filepath.Join("templates", "base.html"),
filepath.Join("templates", page),
)
if err != nil {
log.Fatalf("parse template %s: %v", page, err)
}
templates[page] = t
}
// Public routes // Public pages
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) mux.HandleFunc("GET /login", handleLoginPage)
mux.HandleFunc("/login", handleLogin) mux.HandleFunc("GET /logout", handleLogout)
mux.HandleFunc("/register", handleRegister)
mux.HandleFunc("/logout", handleLogout)
// Protected routes — everything else goes through auth middleware // Static assets (public)
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
// Protected routes
protected := http.NewServeMux() protected := http.NewServeMux()
protected.HandleFunc("/", handleIndex) protected.HandleFunc("GET /{$}", handleIndex)
mux.Handle("/", client.Middleware(protected)) mux.Handle("/", client.Middleware(protected))
} }
func renderPage(w http.ResponseWriter, name string, data interface{}) { func handleIndex(w http.ResponseWriter, r *http.Request) {
t, ok := templates[name] http.ServeFile(w, r, "dist/index.html")
if !ok {
log.Printf("template %s not found", name)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
} }
func handleIndex(w http.ResponseWriter, r *http.Request) { func writeJSON(w http.ResponseWriter, status int, data any) {
if r.URL.Path != "/" { w.Header().Set("Content-Type", "application/json")
http.NotFound(w, r) w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}
renderPage(w, "index.html", nil)
} }

View File

@@ -1,13 +0,0 @@
{{define "base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>patholo — Patent Knowledge for Hogan Lovells</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{{template "content" .}}
</body>
</html>
{{end}}

View File

@@ -1,85 +0,0 @@
{{define "index.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<header class="header">
<div class="container">
<nav class="nav">
<a href="/" class="logo">
<span class="logo-mark">p</span>
<span class="logo-text">patholo</span>
</a>
<div class="nav-right">
<a href="/logout" class="nav-logout">Abmelden</a>
<div class="nav-lang">
<span class="lang-active">DE</span>
<span class="lang-sep">/</span>
<span class="lang-inactive">EN</span>
</div>
</div>
</nav>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<h1>Patent Knowledge<br><span class="hero-accent">für Hogan Lovells</span></h1>
<p class="hero-sub">
Leitfäden, Vorlagen und Dokumente für das HL Patent-Team.
<br>
<span class="hero-en">Guides, templates, and documents for the HL patent team.</span>
</p>
</div>
</section>
<section class="sections">
<div class="container">
<div class="grid">
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>
</div>
<h2>Leitfäden <span class="card-en">Guides</span></h2>
<p>Praxisleitfäden zu Verfahren vor dem EPA, BPatG und UPC. Schritt-für-Schritt-Anleitungen für typische Workflows.</p>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
</div>
<h2>Vorlagen <span class="card-en">Templates</span></h2>
<p>Standardisierte Vorlagen für Schriftsätze, Korrespondenz und interne Dokumente. HL Patents Style Guide.</p>
</div>
<div class="card">
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<h2>Dokumente <span class="card-en">Documents</span></h2>
<p>Referenzmaterialien, Checklisten und Arbeitshilfen für den Praxisalltag im Patentrecht.</p>
</div>
</div>
</div>
</section>
<section class="offices">
<div class="container">
<h3>Standorte <span class="card-en">Offices</span></h3>
<div class="office-list">
<span>München</span>
<span>Düsseldorf</span>
<span>Amsterdam</span>
<span>London</span>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 patholo &mdash; Internal use only. Hogan Lovells Patent Practice.</p>
</div>
</footer>
{{end}}

View File

@@ -1,71 +0,0 @@
{{define "login.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<header class="header">
<div class="container">
<nav class="nav">
<a href="/" class="logo">
<span class="logo-mark">p</span>
<span class="logo-text">patholo</span>
</a>
</nav>
</div>
</header>
<main class="login-main">
<div class="login-card">
<div class="login-tabs">
<button class="login-tab{{if ne .Mode "register"}} active{{end}}" data-tab="login">Anmelden</button>
<button class="login-tab{{if eq .Mode "register"}} active{{end}}" data-tab="register">Registrieren</button>
</div>
{{if .Error}}
<div class="login-error">{{.Error}}</div>
{{end}}
{{if .Success}}
<div class="login-success">{{.Success}}</div>
{{end}}
<form method="POST" action="/login" class="login-form" id="login-form"{{if eq .Mode "register"}} style="display:none"{{end}}>
<label for="login-email" class="login-label">E-Mail</label>
<input type="email" id="login-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required autofocus class="login-input">
<label for="login-password" class="login-label">Passwort</label>
<input type="password" id="login-password" name="password" placeholder="Passwort" required class="login-input">
<button type="submit" class="login-button">Anmelden</button>
</form>
<form method="POST" action="/register" class="login-form" id="register-form"{{if ne .Mode "register"}} style="display:none"{{end}}>
<label for="reg-email" class="login-label">E-Mail</label>
<input type="email" id="reg-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required class="login-input">
<label for="reg-password" class="login-label">Passwort</label>
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" class="login-input">
<label for="reg-confirm" class="login-label">Passwort bestätigen</label>
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" class="login-input">
<button type="submit" class="login-button">Registrieren</button>
</form>
<p class="login-hint">Nur für @hoganlovells.com Adressen.</p>
</div>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 patholo &mdash; Internal use only. Hogan Lovells Patent Practice.</p>
</div>
</footer>
<script>
document.querySelectorAll('.login-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.login-tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('login-form').style.display = btn.dataset.tab === 'login' ? '' : 'none';
document.getElementById('register-form').style.display = btn.dataset.tab === 'register' ? '' : 'none';
document.querySelectorAll('.login-error, .login-success').forEach(function(el) { el.style.display = 'none'; });
});
});
</script>
{{end}}