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:
@@ -4,3 +4,5 @@
|
||||
.m
|
||||
*.md
|
||||
!README.md
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,10 @@
|
||||
/patholo
|
||||
*.exe
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -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
|
||||
COPY go.mod ./
|
||||
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
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=build /patholo /app/patholo
|
||||
COPY templates/ /app/templates/
|
||||
COPY static/ /app/static/
|
||||
|
||||
COPY --from=backend /patholo /app/patholo
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
EXPOSE 8080
|
||||
CMD ["/app/patholo"]
|
||||
|
||||
42
frontend/build.ts
Normal file
42
frontend/build.ts
Normal 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
21
frontend/bun.lock
Normal 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
2
frontend/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[install]
|
||||
peer = false
|
||||
10
frontend/package.json
Normal file
10
frontend/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "patholo-frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bun run build.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
108
frontend/src/client/login.ts
Normal file
108
frontend/src/client/login.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
frontend/src/components/Footer.tsx
Normal file
11
frontend/src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/Header.tsx
Normal file
30
frontend/src/components/Header.tsx
Normal 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
74
frontend/src/index.tsx
Normal 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ür Hogan Lovells</span></h1>
|
||||
<p className="hero-sub">
|
||||
Leitfäden, Vorlagen und Dokumente fü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äden <span className="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 className="card">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FILE }} />
|
||||
<h2>Vorlagen <span className="card-en">Templates</span></h2>
|
||||
<p>Standardisierte Vorlagen für Schriftsä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ü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ünchen</span>
|
||||
<span>Düsseldorf</span>
|
||||
<span>Amsterdam</span>
|
||||
<span>London</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
62
frontend/src/jsx.ts
Normal file
62
frontend/src/jsx.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
52
frontend/src/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ main {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
/* --- Header --- */
|
||||
|
||||
.header {
|
||||
background: var(--color-surface);
|
||||
@@ -117,7 +117,7 @@ main {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ─── Hero ─── */
|
||||
/* --- Hero --- */
|
||||
|
||||
.hero {
|
||||
background: var(--color-hero-bg);
|
||||
@@ -151,7 +151,7 @@ main {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ─── Card Grid ─── */
|
||||
/* --- Card Grid --- */
|
||||
|
||||
.sections {
|
||||
padding: 4rem 0;
|
||||
@@ -207,7 +207,7 @@ main {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ─── Offices ─── */
|
||||
/* --- Offices --- */
|
||||
|
||||
.offices {
|
||||
padding: 0 0 4rem;
|
||||
@@ -252,7 +252,7 @@ main {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ─── Footer ─── */
|
||||
/* --- Footer --- */
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -266,7 +266,7 @@ main {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Login ─── */
|
||||
/* --- Login --- */
|
||||
|
||||
.login-main {
|
||||
flex: 1;
|
||||
@@ -401,7 +401,7 @@ main {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
/* --- Responsive --- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -9,24 +10,6 @@ import (
|
||||
"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) {
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
http.ServeFile(w, r, "dist/login.html")
|
||||
}
|
||||
|
||||
func handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: "Bitte E-Mail und Passwort eingeben.",
|
||||
Email: email,
|
||||
})
|
||||
func handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
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
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(email) {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: "Zugang nur für @hoganlovells.com E-Mail-Adressen.",
|
||||
Email: email,
|
||||
})
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte E-Mail und Passwort eingeben."})
|
||||
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 {
|
||||
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."
|
||||
if strings.Contains(err.Error(), "Invalid login credentials") {
|
||||
errMsg = "Ungültige E-Mail-Adresse oder Passwort."
|
||||
}
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: errMsg,
|
||||
Email: email,
|
||||
})
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
func handleAPIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
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
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
|
||||
if email == "" || password == "" {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Bitte alle Felder ausfüllen.",
|
||||
Email: email,
|
||||
})
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bitte alle Felder ausfüllen."})
|
||||
return
|
||||
}
|
||||
|
||||
if password != confirm {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Passwörter stimmen nicht überein.",
|
||||
Email: email,
|
||||
})
|
||||
if len(req.Password) < 8 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Passwort muss mindestens 8 Zeichen lang sein."})
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
Email: email,
|
||||
})
|
||||
if !isHoganLovellsEmail(req.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für @hoganlovells.com E-Mail-Adressen."})
|
||||
return
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(email) {
|
||||
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)
|
||||
tokens, err := authClient.SignUp(req.Email, req.Password)
|
||||
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."
|
||||
if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") {
|
||||
errMsg = "Ein Account mit dieser E-Mail existiert bereits."
|
||||
}
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: errMsg,
|
||||
Email: email,
|
||||
})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
if tokens != nil {
|
||||
auth.SetAuthCookies(w, r, tokens)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"redirect": "/"})
|
||||
return
|
||||
}
|
||||
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Success: "Account erstellt. Bitte melden Sie sich an.",
|
||||
Email: email,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Account erstellt. Bitte melden Sie sich an."})
|
||||
}
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,65 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
templates map[string]*template.Template
|
||||
authClient *auth.Client
|
||||
)
|
||||
var authClient *auth.Client
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client) {
|
||||
authClient = client
|
||||
|
||||
// Parse each page template separately so "content" blocks don't collide
|
||||
templates = make(map[string]*template.Template)
|
||||
for _, page := range []string{"index.html", "login.html"} {
|
||||
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
|
||||
}
|
||||
// API endpoints (JSON, public)
|
||||
mux.HandleFunc("POST /api/login", handleAPILogin)
|
||||
mux.HandleFunc("POST /api/register", handleAPIRegister)
|
||||
|
||||
// Public routes
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
mux.HandleFunc("/login", handleLogin)
|
||||
mux.HandleFunc("/register", handleRegister)
|
||||
mux.HandleFunc("/logout", handleLogout)
|
||||
// Public pages
|
||||
mux.HandleFunc("GET /login", handleLoginPage)
|
||||
mux.HandleFunc("GET /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.HandleFunc("/", handleIndex)
|
||||
protected.HandleFunc("GET /{$}", handleIndex)
|
||||
mux.Handle("/", client.Middleware(protected))
|
||||
}
|
||||
|
||||
func renderPage(w http.ResponseWriter, name string, data interface{}) {
|
||||
t, ok := templates[name]
|
||||
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) {
|
||||
http.ServeFile(w, r, "dist/index.html")
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
renderPage(w, "index.html", nil)
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>© 2026 patholo — Internal use only. Hogan Lovells Patent Practice.</p>
|
||||
</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
@@ -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>© 2026 patholo — 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}}
|
||||
Reference in New Issue
Block a user