Migrate to mcdsl: auth, config, csrf, web

- Replace internal/auth with mcdsl/auth
- Replace internal/config with mcdsl/config (embed config.Base)
- Replace internal/webserver/csrf.go with mcdsl/csrf
- Use mcdsl/web for session cookies and template rendering
- Use mcdsl/httpserver for server setup and StatusWriter
- Remove direct mcias client library dependency
- Update .golangci.yaml to v2 format (formatters section)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:53:15 -07:00
commit 0cada7e64e
21 changed files with 1042 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
srv/
mcat
*.db
*.db-wal
*.db-shm
.idea/
.vscode/

80
.golangci.yaml Normal file
View File

@@ -0,0 +1,80 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- unused
- errorlint
- gosec
- staticcheck
- revive
settings:
errcheck:
check-blank: false
check-type-assertions: true
govet:
enable-all: true
disable:
- shadow
- fieldalignment
gosec:
severity: medium
confidence: medium
excludes:
- G104
errorlint:
errorf: true
asserts: true
comparison: true
revive:
rules:
- name: error-return
severity: error
- name: unexported-return
severity: error
- name: error-strings
severity: warning
- name: if-return
severity: warning
- name: increment-decrement
severity: warning
- name: var-naming
severity: warning
- name: range
severity: warning
- name: time-naming
severity: warning
- name: indent-error-flow
severity: warning
- name: early-return
severity: warning
formatters:
enable:
- gofmt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclusions:
paths:
- vendor
rules:
- path: "_test\\.go"
linters:
- gosec
text: "G101"

96
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,96 @@
# Architecture
## Overview
mcat is a lightweight web application for testing MCIAS login policies. It
presents a login form, forwards credentials (along with a configurable
`service_name` and `tags`) to an MCIAS instance, and displays whether the
login was accepted or denied. This lets operators verify that login policy
rules behave as expected for a given service context.
## System Diagram
```
┌──────────────┐ HTTPS ┌──────────────┐
│ Browser │◄──────────────────►│ mcat │
│ (htmx UI) │ │ web server │
└──────────────┘ └──────┬───────┘
│ HTTPS (Login, ValidateToken)
┌──────────────┐
│ MCIAS │
│ (auth) │
└──────────────┘
```
## Components
### Web Server (`internal/webserver/`)
Single chi-based HTTP server serving the web UI over TLS. Handles:
- **Login flow**: Renders login form, submits credentials to MCIAS via the
auth package, sets session cookie on success.
- **Session management**: HttpOnly/Secure/SameSite=Strict cookies containing
the MCIAS bearer token.
- **CSRF protection**: HMAC-SHA256 double-submit cookies on all mutating
requests.
- **Template rendering**: Go `html/template` with a layout + page block
pattern. Templates and static files are embedded via `//go:embed`.
### Auth Package (`internal/auth/`)
Wraps the MCIAS Go client library. Provides:
- `Login(username, password, totpCode)` — Forwards to MCIAS with the
configured `service_name` and `tags`. Returns the bearer token.
- `ValidateToken(token)` — Validates against MCIAS with 30-second cache
(keyed by SHA-256 of token).
- `Logout()` — Revokes the token on MCIAS.
### Configuration (`internal/config/`)
TOML configuration loaded at startup. Required fields are validated; the
service refuses to start if any are missing.
### CLI (`cmd/mcat/`)
Cobra-based. Subcommands: `server` (start), `version`.
## Configuration Reference
```toml
[server]
listen_addr = ":8443" # HTTPS listen address
tls_cert = "cert.pem" # TLS certificate path
tls_key = "key.pem" # TLS key path
[mcias]
server_url = "https://..." # MCIAS server URL
ca_cert = "" # Optional CA cert for MCIAS TLS
service_name = "mcat" # Sent with login requests for policy eval
tags = [] # Sent with login requests for policy eval
[log]
level = "info" # debug, info, warn, error
```
## Web Routes
| Method | Path | Auth | Description |
|--------|-------------|------|--------------------------|
| GET | `/` | No | Redirect to /dashboard or /login |
| GET | `/login` | No | Login form |
| POST | `/login` | No | Submit login |
| POST | `/logout` | Yes | Revoke token, clear cookie |
| GET | `/dashboard`| Yes | Session info display |
| GET | `/static/*` | No | Embedded static files |
## Security
- TLS 1.3 minimum, no fallback.
- CSRF via HMAC-SHA256 double-submit cookies.
- Session cookies: HttpOnly, Secure, SameSite=Strict.
- All authentication delegated to MCIAS — no local user database.
- Token validation cached for 30 seconds to reduce MCIAS load.

46
CLAUDE.md Normal file
View File

@@ -0,0 +1,46 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
mcat is a lightweight web application for testing MCIAS login policies. It presents a login form, forwards credentials (with configurable `service_name` and `tags`) to MCIAS, and shows whether the login was accepted or denied by policy. Single binary, no database, no gRPC.
Module path: `git.wntrmute.dev/kyle/mcat`
MCIAS client library: `git.wntrmute.dev/kyle/mcias/clients/go` (imported as `mcias`), local replace directive in go.mod.
## Build Commands
```bash
make mcat # Build the mcat binary (stripped, version-injected)
make build # Build all packages
make test # Run all tests
make vet # Run go vet
make lint # Run golangci-lint v2
make all # Full pipeline: vet → lint → test → build
make devserver # Build and run locally against srv/mcat.toml
```
Run a single test:
```bash
go test ./internal/auth/ -run TestLoginSuccess
```
## Architecture
- `cmd/mcat/` — Cobra CLI entry point. `server` subcommand wires config → auth → webserver.
- `internal/auth/` — Wraps MCIAS client for login/logout/token validation with 30s cache.
- `internal/config/` — TOML config loading and validation.
- `internal/webserver/` — Chi-based web server with CSRF (HMAC-SHA256 double-submit cookies), session cookies, and template rendering.
- `web/` — Embedded templates (layout + page blocks) and static files (htmx, CSS).
- `deploy/` — Dockerfile, systemd unit, install script, example config.
- `srv/` — Local dev data directory (gitignored).
## Critical Rules
- **No test frameworks**: Use stdlib `testing` only.
- **Auth via MCIAS only**: No local user databases.
- **TLS 1.3 minimum**, no exceptions.
- **CSRF on all mutations**: Double-submit cookie pattern, validated in middleware.
- **Session cookies**: HttpOnly, Secure, SameSite=Strict.

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" -o mcat ./cmd/mcat
FROM alpine:3.21
RUN apk --no-cache add ca-certificates && \
adduser -D -h /srv/mcat mcat
USER mcat
WORKDIR /srv/mcat
COPY --from=builder /build/mcat /usr/local/bin/mcat
VOLUME /srv/mcat
EXPOSE 8443
ENTRYPOINT ["mcat"]
CMD ["server", "--config", "/srv/mcat/mcat.toml"]

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY: build test vet lint clean all devserver
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
mcat:
go build $(LDFLAGS) -o mcat ./cmd/mcat
build:
go build ./...
test:
go test ./...
vet:
go vet ./...
lint:
golangci-lint run ./...
clean:
rm -f mcat
all: vet lint test mcat
devserver: mcat
./mcat server --config srv/mcat.toml

39
README.md Normal file
View File

@@ -0,0 +1,39 @@
# mcat
mcat is a lightweight web application for testing and auditing MCIAS login
policies. It presents a login form that forwards credentials, along with a
configurable `service_name` and `tags`, to an MCIAS instance. This lets
operators verify that login policy rules behave as expected for a given
service context.
It follows the standard Metacircular Dynamics engineering standards.
## Quick Start
```bash
# Build
make mcat
# Configure (copy and edit the example config)
mkdir -p srv/certs
cp deploy/examples/mcat.toml.example srv/mcat.toml
# Edit srv/mcat.toml with your MCIAS URL, TLS certs, service_name, and tags
# Run
./mcat server --config srv/mcat.toml
```
Then open `https://localhost:8443` in a browser.
## Build
```bash
make all # vet, lint, test, build
make test # tests only
make lint # golangci-lint
```
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design, routes, config reference
- [RUNBOOK.md](RUNBOOK.md) — operational procedures

68
RUNBOOK.md Normal file
View File

@@ -0,0 +1,68 @@
# Runbook
## Service Overview
mcat is a web application for testing MCIAS login policies. It runs a TLS
web server that lets users log in via MCIAS with a configurable service
name and tag set.
## Health Check
1. Open `https://<host>:8443/login` in a browser.
2. If the login form renders, the service is healthy.
## Common Operations
### Start
```bash
systemctl start mcat
```
### Stop
```bash
systemctl stop mcat
```
### View Logs
```bash
journalctl -u mcat -f
```
### Run Locally
```bash
make mcat
./mcat server --config srv/mcat.toml
```
## Configuration
Config file: `/srv/mcat/mcat.toml`
After changing config, restart the service:
```bash
systemctl restart mcat
```
## TLS Certificate Renewal
1. Replace `/srv/mcat/certs/cert.pem` and `/srv/mcat/certs/key.pem`.
2. Restart: `systemctl restart mcat`.
## MCIAS Unreachable
If MCIAS is down, logins and token validation will fail. Users will see
generic error messages. Check MCIAS connectivity:
```bash
curl -k https://<mcias-host>:8443/v1/health
```
## Escalation
If the issue is not covered above, check MCIAS logs and status. mcat has
no local state — all authentication is delegated to MCIAS.

View File

@@ -0,0 +1,13 @@
[server]
listen_addr = ":8443"
tls_cert = "/srv/mcat/certs/cert.pem"
tls_key = "/srv/mcat/certs/key.pem"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = ""
service_name = "mcat"
tags = []
[log]
level = "info"

32
deploy/scripts/install.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
SERVICE=mcat
SRV_DIR="/srv/${SERVICE}"
BIN_DIR="/usr/local/bin"
# Create system user (idempotent).
if ! id "${SERVICE}" >/dev/null 2>&1; then
useradd --system --shell /usr/sbin/nologin --home-dir "${SRV_DIR}" "${SERVICE}"
echo "Created system user: ${SERVICE}"
fi
# Install binary.
install -m 0755 "${SERVICE}" "${BIN_DIR}/${SERVICE}"
echo "Installed ${BIN_DIR}/${SERVICE}"
# Create data directory structure.
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}"
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}/certs"
# Copy example config if none exists.
if [ ! -f "${SRV_DIR}/${SERVICE}.toml" ]; then
install -o "${SERVICE}" -g "${SERVICE}" -m 0600 \
deploy/examples/${SERVICE}.toml.example "${SRV_DIR}/${SERVICE}.toml"
echo "Installed example config to ${SRV_DIR}/${SERVICE}.toml"
fi
# Install systemd units.
install -m 0644 deploy/systemd/${SERVICE}.service /etc/systemd/system/
systemctl daemon-reload
echo "Installed systemd unit. Enable with: systemctl enable --now ${SERVICE}"

View File

@@ -0,0 +1,29 @@
[Unit]
Description=mcat - MCIAS Login Policy Tester
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mcat
Group=mcat
ExecStart=/usr/local/bin/mcat server --config /srv/mcat/mcat.toml
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
ReadWritePaths=/srv/mcat
[Install]
WantedBy=multi-user.target

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module git.wntrmute.dev/kyle/mcat
go 1.25.7
require (
git.wntrmute.dev/kyle/mcdsl v0.0.0
github.com/go-chi/chi/v5 v5.2.5
github.com/spf13/cobra v1.10.2
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
replace git.wntrmute.dev/kyle/mcdsl => /home/kyle/src/metacircular/mcdsl

14
go.sum Normal file
View File

@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,191 @@
package webserver
import (
"crypto/rand"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/mcdsl/csrf"
"git.wntrmute.dev/kyle/mcdsl/httpserver"
"git.wntrmute.dev/kyle/mcdsl/web"
mcatweb "git.wntrmute.dev/kyle/mcat/web"
)
const (
sessionCookieName = "mcat_token"
csrfCookieName = "mcat_csrf"
csrfFieldName = "csrf_token"
)
// Config holds the webserver configuration. Extracted from the service
// config so the webserver doesn't depend on the full config package.
type Config struct {
ServiceName string
Tags []string
}
// Server is the mcat web UI server.
type Server struct {
wsCfg Config
auth *auth.Authenticator
logger *slog.Logger
csrf *csrf.Protect
staticFS fs.FS
handler http.Handler
}
// New creates a new web UI server.
func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) {
staticFS, err := fs.Sub(mcatweb.FS, "static")
if err != nil {
return nil, fmt.Errorf("webserver: static fs: %w", err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("webserver: generate CSRF secret: %w", err)
}
s := &Server{
wsCfg: wsCfg,
auth: authenticator,
logger: logger,
csrf: csrf.New(secret, csrfCookieName, csrfFieldName),
staticFS: staticFS,
}
r := chi.NewRouter()
r.Use(s.loggingMiddleware)
r.Use(s.csrf.Middleware)
s.registerRoutes(r)
s.handler = r
return s, nil
}
// Handler returns the HTTP handler for the web server.
func (s *Server) Handler() http.Handler {
return s.handler
}
func (s *Server) registerRoutes(r chi.Router) {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS))))
r.Get("/", s.handleRoot)
r.Get("/login", s.handleLogin)
r.Post("/login", s.handleLogin)
r.Post("/logout", s.requireAuth(s.handleLogout))
r.Get("/dashboard", s.requireAuth(s.handleDashboard))
}
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, sessionCookieName)
if token != "" {
if _, err := s.auth.ValidateToken(token); err == nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.renderTemplate(w, "login.html", map[string]interface{}{})
return
}
if err := r.ParseForm(); err != nil { //nolint:gosec // form size bounded by http.Server ReadTimeout
s.renderTemplate(w, "login.html", map[string]interface{}{
"Error": "Invalid form data.",
})
return
}
username := r.FormValue("username") //nolint:gosec // parsed above
password := r.FormValue("password") //nolint:gosec // parsed above
totpCode := r.FormValue("totp_code") //nolint:gosec // parsed above
token, _, err := s.auth.Login(username, password, totpCode)
if err != nil {
msg := "Login failed."
if errors.Is(err, auth.ErrInvalidCredentials) {
msg = "Invalid username or password."
} else if errors.Is(err, auth.ErrForbidden) {
msg = "Login denied by policy."
}
s.renderTemplate(w, "login.html", map[string]interface{}{
"Error": msg,
})
return
}
web.SetSessionCookie(w, sessionCookieName, token)
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, sessionCookieName)
if token != "" {
if err := s.auth.Logout(token); err != nil {
s.logger.Warn("logout failed", "error", err)
}
}
web.ClearSessionCookie(w, sessionCookieName)
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
info := auth.TokenInfoFromContext(r.Context())
s.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"Roles": info.Roles,
"IsAdmin": info.IsAdmin,
"ServiceName": s.wsCfg.ServiceName,
"Tags": s.wsCfg.Tags,
})
}
// requireAuth wraps a handler that requires a valid MCIAS session.
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := web.GetSessionToken(r, sessionCookieName)
if token == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
info, err := s.auth.ValidateToken(token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := auth.ContextWithTokenInfo(r.Context(), info)
next(w, r.WithContext(ctx))
}
}
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
web.RenderTemplate(w, mcatweb.FS, name, data, s.csrf.TemplateFunc(w))
}
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &httpserver.StatusWriter{ResponseWriter: w, Status: http.StatusOK}
next.ServeHTTP(sw, r)
s.logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", sw.Status,
"remote", r.RemoteAddr,
)
})
}

13
mcat.toml Normal file
View File

@@ -0,0 +1,13 @@
[server]
listen_addr = ":7443"
tls_cert = "/Users/kyle/tmp/ca/mc-localhost/cert.pem"
tls_key = "/Users/kyle/tmp/ca/mc-localhost/key.pem"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = "/Users/kyle/tmp/ca/ca.pem"
service_name = "mcat"
tags = ["env:restricted"]
[log]
level = "info"

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed templates static
var FS embed.FS

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

270
web/static/style.css Normal file
View File

@@ -0,0 +1,270 @@
/* mcat — Nord dark theme */
/* ===========================
Colour tokens (Nord palette)
=========================== */
:root {
--n0: #2E3440;
--n1: #3B4252;
--n2: #434C5E;
--n3: #4C566A;
--s0: #D8DEE9;
--s1: #E5E9F0;
--s2: #ECEFF4;
--f0: #8FBCBB;
--f1: #88C0D0;
--f2: #81A1C1;
--f3: #5E81AC;
--red: #BF616A;
--green: #A3BE8C;
}
/* ===========================
Reset
=========================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; }
/* ===========================
Base
=========================== */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--n0);
color: var(--s0);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--f1); text-decoration: none; }
a:hover { color: var(--f0); text-decoration: underline; }
p { margin-bottom: 0.875rem; }
h2 { font-size: 1.375rem; font-weight: 600; color: var(--s2); margin-bottom: 0.25rem; }
code {
font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
font-size: 0.8125rem;
color: var(--f0);
background: var(--n2);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
/* ===========================
Top navigation
=========================== */
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 52px;
background: var(--n1);
border-bottom: 1px solid var(--n3);
position: sticky;
top: 0;
z-index: 100;
}
.topnav-brand {
font-size: 1rem;
font-weight: 700;
color: var(--s2);
text-decoration: none;
letter-spacing: 0.04em;
}
.topnav-brand:hover { color: var(--f1); text-decoration: none; }
.topnav-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.topnav-user {
font-size: 0.875rem;
color: var(--s1);
}
/* ===========================
Page containers
=========================== */
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.auth-container {
max-width: 420px;
margin: 5rem auto 2rem;
padding: 0 1rem;
}
/* ===========================
Auth pages
=========================== */
.auth-header {
text-align: center;
margin-bottom: 1.75rem;
}
.auth-header .brand {
font-size: 1.5rem;
font-weight: 700;
color: var(--s2);
letter-spacing: 0.04em;
}
.auth-header .tagline {
font-size: 0.6875rem;
color: var(--f2);
text-transform: uppercase;
letter-spacing: 0.12em;
margin-top: 0.25rem;
}
/* ===========================
Cards
=========================== */
.card {
background: var(--n1);
border: 1px solid var(--n3);
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.card:last-child { margin-bottom: 0; }
.card-title {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--f2);
margin-bottom: 1rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid var(--n2);
}
.card p:last-child { margin-bottom: 0; }
/* ===========================
Alerts
=========================== */
.error {
background: rgba(191, 97, 106, 0.12);
color: #e07c82;
border: 1px solid rgba(191, 97, 106, 0.3);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.success {
background: rgba(163, 190, 140, 0.1);
border: 1px solid rgba(163, 190, 140, 0.3);
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--green);
}
/* ===========================
Buttons
=========================== */
button, .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
border: 1px solid var(--f3);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, color 0.12s;
line-height: 1.4;
background: var(--f3);
color: var(--s2);
}
button:hover, .btn:hover {
background: var(--f2);
border-color: var(--f2);
text-decoration: none;
color: var(--s2);
}
.btn-ghost {
background: transparent;
color: var(--s0);
border-color: var(--n3);
}
.btn-ghost:hover {
background: var(--n2);
color: var(--s1);
border-color: var(--n3);
text-decoration: none;
}
/* ===========================
Forms
=========================== */
.form-group { margin-bottom: 1rem; }
.form-group label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
color: var(--s0);
margin-bottom: 0.375rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.form-group input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--n0);
border: 1px solid var(--n3);
border-radius: 4px;
color: var(--s1);
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
-webkit-appearance: none;
appearance: none;
}
.form-group input:focus {
outline: none;
border-color: var(--f3);
box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2);
}
.form-group input::placeholder { color: var(--n3); }
.form-actions { margin-top: 0.25rem; }
/* ===========================
Session info
=========================== */
.session-info {
font-size: 0.875rem;
color: var(--s0);
}
.session-info dt {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--f2);
margin-bottom: 0.25rem;
}
.session-info dd {
margin-bottom: 0.75rem;
margin-left: 0;
}
.session-info dd:last-child { margin-bottom: 0; }
.role-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
background: rgba(94, 129, 172, 0.2);
color: var(--f1);
border: 1px solid rgba(94, 129, 172, 0.35);
margin-right: 0.25rem;
}

View File

@@ -0,0 +1,19 @@
{{define "title"}} - Dashboard{{end}}
{{define "content"}}
<div class="card">
<div class="card-title">Session</div>
<div class="success">Login successful. MCIAS accepted this service context.</div>
<dl class="session-info">
<dt>Username</dt>
<dd>{{.Username}}</dd>
<dt>Roles</dt>
<dd>{{range .Roles}}<span class="role-tag">{{.}}</span>{{end}}</dd>
<dt>Service Name</dt>
<dd><code>{{.ServiceName}}</code></dd>
{{if .Tags}}
<dt>Tags</dt>
<dd>{{range .Tags}}<code>{{.}}</code> {{end}}</dd>
{{end}}
</dl>
</div>
{{end}}

27
web/templates/layout.html Normal file
View File

@@ -0,0 +1,27 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcat{{block "title" .}}{{end}}</title>
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-brand">mcat</a>
{{if .Username}}
<div class="topnav-right">
<span class="topnav-user">{{.Username}}</span>
<form method="POST" action="/logout" style="margin:0">
{{csrfField}}
<button type="submit" class="btn-ghost btn">Logout</button>
</form>
</div>
{{end}}
</nav>
<div class="{{block "container-class" .}}page-container{{end}}">
{{template "content" .}}
</div>
</body>
</html>{{end}}

30
web/templates/login.html Normal file
View File

@@ -0,0 +1,30 @@
{{define "title"}} - Login{{end}}
{{define "container-class"}}auth-container{{end}}
{{define "content"}}
<div class="auth-header">
<div class="brand">mcat</div>
<div class="tagline">MCIAS Login Policy Tester</div>
</div>
<div class="card">
<div class="card-title">Sign In</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
{{csrfField}}
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<div class="form-group">
<label for="totp_code">TOTP Code (optional)</label>
<input type="text" id="totp_code" name="totp_code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" placeholder="6-digit code">
</div>
<div class="form-actions">
<button type="submit">Login</button>
</div>
</form>
</div>
{{end}}