Add basic login screen with form-based authentication

Adds /login route with HTML template that handles GET (show form) and
POST (authenticate). On successful login, sets session cookie and
redirects to /. Also adds framework support for redirects and cookies
in route handlers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 10:07:02 -06:00
parent 7cecf5326d
commit 1c1eeddcbe
7 changed files with 188 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ import {
AuthorizationDenied,
type Call,
type InternalHandler,
isRedirect,
type Method,
massageMethod,
methodParser,
@@ -25,8 +26,9 @@ import {
const app = express();
// Parse JSON request bodies
// Parse request bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
services.logging.log({ source: "logging", text: ["1"] });
const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
@@ -111,8 +113,10 @@ async function handler(
const method = await methodParser.parseAsync(req.method);
const byMethod = processedRoutes[method];
console.log("DEBUG: req.path =", JSON.stringify(req.path), "method =", method);
for (const [_idx, pr] of byMethod.entries()) {
const match = pr.matcher(req.url);
const match = pr.matcher(req.path);
console.log("DEBUG: trying pattern, match result =", match);
if (match) {
console.log("match", match);
const resp = await pr.handler(req);
@@ -124,7 +128,7 @@ async function handler(
const retval: Result = {
code: httpCodes.clientErrors.NotFound,
contentType: contentTypes.text.plain,
result: "not found",
result: "not found!",
};
return retval;
@@ -138,7 +142,18 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => {
console.log(result);
res.status(code).send(result);
// Set any cookies from the result
if (result0.cookies) {
for (const cookie of result0.cookies) {
res.cookie(cookie.name, cookie.value, cookie.options ?? {});
}
}
if (isRedirect(result0)) {
res.redirect(code, result0.redirect);
} else {
res.status(code).send(result);
}
});
process.title = `diachron:${cli.listen.port}`;

62
express/basic/login.ts Normal file
View File

@@ -0,0 +1,62 @@
import { SESSION_COOKIE_NAME } from "../auth/token";
import { tokenLifetimes } from "../auth/types";
import { services } from "../services";
import type { Call, Result, Route } from "../types";
import { html, redirect, render } from "../util";
const loginHandler = async (call: Call): Promise<Result> => {
if (call.method === "GET") {
const c = await render("basic/login", {});
return html(c);
}
// POST - handle login
const { email, password } = call.request.body;
if (!email || !password) {
const c = await render("basic/login", {
error: "Email and password are required",
email,
});
return html(c);
}
const result = await services.auth.login(email, password, "cookie", {
userAgent: call.request.get("User-Agent"),
ipAddress: call.request.ip,
});
if (!result.success) {
const c = await render("basic/login", {
error: result.error,
email,
});
return html(c);
}
// Success - set cookie and redirect to home
const redirectResult = redirect("/");
redirectResult.cookies = [
{
name: SESSION_COOKIE_NAME,
value: result.token,
options: {
httpOnly: true,
secure: false, // Set to true in production with HTTPS
sameSite: "lax",
maxAge: tokenLifetimes.session,
path: "/",
},
},
];
return redirectResult;
};
const loginRoute: Route = {
path: "/login",
methods: ["GET", "POST"],
handler: loginHandler,
};
export { loginRoute };

View File

@@ -1,18 +1,29 @@
import { DateTime } from "ts-luxon";
import type { Call, Result, Route } from "../types";
import { html, render } from "../util";
import { loginRoute } from "./login";
const routes: Record<string, Route> = {
hello: {
path: "/hello",
methods: ["GET"],
handler: async (call: Call): Promise<Result> => {
handler: async (_call: Call): Promise<Result> => {
const now = DateTime.now();
const c = await render("basic/hello", { now });
return html(c);
},
},
home: {
path:'/',
methods:['GET'],
handler:async(_call:Call): Promise<Result> => {
const c = await render('basic/home')
return html(c)
}
},
login: loginRoute,
};
export { routes };

View File

@@ -25,7 +25,9 @@ const okText = (result: string): Result => {
const routes: Route[] = [
...authRoutes,
basicRoutes.home,
basicRoutes.hello,
basicRoutes.login,
{
path: "/slow",
methods: ["GET"],

View File

@@ -49,12 +49,35 @@ export type ProcessedRoute = {
handler: InternalHandler;
};
export type CookieOptions = {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
maxAge?: number;
path?: string;
};
export type Cookie = {
name: string;
value: string;
options?: CookieOptions;
};
export type Result = {
code: HttpCode;
contentType: ContentType;
result: string;
cookies?: Cookie[];
};
export type RedirectResult = Result & {
redirect: string;
};
export function isRedirect(result: Result): result is RedirectResult {
return "redirect" in result;
}
export type Route = {
path: string;
methods: Method[];

View File

@@ -3,7 +3,7 @@ import nunjucks from "nunjucks";
import { contentTypes } from "./content-types";
import { executionContext } from "./execution-context";
import { httpCodes } from "./http-codes";
import type { Result } from "./types";
import type { Result, RedirectResult } from "./types";
// FIXME: Handle the error here
const loadFile = async (path: string): Promise<string> => {
@@ -13,12 +13,14 @@ const loadFile = async (path: string): Promise<string> => {
return data;
};
const render = async (path: string, ctx: object): Promise<string> => {
const render = async (path: string, ctx?: object): Promise<string> => {
const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`;
const template = await loadFile(fullPath);
const retval = nunjucks.renderString(template, ctx);
const c = ctx===undefined ? {} : ctx;
const retval = nunjucks.renderString(template, c);
return retval;
};
@@ -33,4 +35,13 @@ const html = (payload: string): Result => {
return retval;
};
export { render, html };
const redirect = (location: string): RedirectResult => {
return {
code: httpCodes.redirection.SeeOther,
contentType: contentTypes.text.plain,
result: "",
redirect: location,
};
};
export { render, html, redirect };

View File

@@ -0,0 +1,55 @@
<html>
<head>
<title>Login</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 20px;
}
form {
display: flex;
flex-direction: column;
gap: 15px;
}
label {
display: flex;
flex-direction: column;
gap: 5px;
}
input {
padding: 8px;
font-size: 16px;
}
button {
padding: 10px;
font-size: 16px;
cursor: pointer;
}
.error {
color: red;
padding: 10px;
background: #fee;
border: 1px solid #fcc;
}
</style>
</head>
<body>
<h1>Login</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<label>
Email
<input type="email" name="email" required value="{{ email | default('') }}">
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>