This commit is contained in:
2026-09-09 17:49:01 +01:00
commit 99c15e23bb
30 changed files with 3028 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""bike-admin package."""
+54
View File
@@ -0,0 +1,54 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Core
data_dir: Path = Field(default=BASE_DIR / "data")
db_path: Path | None = None # defaults to <data_dir>/bike_admin.db when None
secret_key: str = Field(default="change-me", min_length=8) # CSRF/session HMAC
username: str = Field(default="admin", min_length=1)
password: str | None = None # REQUIRED on first run (see ensure_user)
# Hosting posture
is_production: bool = False # True => Secure cookies + stricter headers
bind_host: str = "127.0.0.1"
bind_port: int = 8000
trust_proxy: bool = True # honor X-Forwarded-For/Proto behind a reverse proxy
# Sessions
session_ttl_seconds: int = 7 * 24 * 3600
# Login throttling
login_max_attempts: int = 8
login_window_seconds: int = 300
# Service hint: flag a bike as "book soon" when it is this close to due.
service_due_soon_miles: int = 200
# Event types (preset)
event_types: list[str] = [
"service",
"fuel",
"tyre",
"chain",
"brake",
"repair",
"other",
]
model_config = SettingsConfigDict(env_prefix="BIKE_ADMIN_", env_file=".env", extra="ignore")
@property
def resolved_db_path(self) -> Path:
return self.db_path or (self.data_dir / "bike_admin.db")
@lru_cache
def get_settings() -> Settings:
return Settings()
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from .config import get_settings
SCHEMA = """
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT NOT NULL,
ts TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bikes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
service_interval_miles INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bike_id INTEGER NOT NULL REFERENCES bikes(id) ON DELETE CASCADE,
type TEXT NOT NULL,
event_date TEXT NOT NULL,
mileage INTEGER,
cost INTEGER,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_bike_date ON events(bike_id, event_date DESC);
"""
@dataclass
class Bike:
id: int
name: str
service_interval_miles: int | None
@dataclass
class BikeEvent:
id: int
bike_id: int
type: str
event_date: date
mileage: int | None
cost: int | None
notes: str | None
def connect(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
conn.commit()
return conn
def init_schema(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = connect(db_path)
try:
conn.executescript(SCHEMA)
conn.commit()
finally:
conn.close()
def ensure_user(db_path: Path, username: str, password: str, password_hash: str) -> None:
conn = connect(db_path)
try:
cur = conn.execute("SELECT id FROM users WHERE username = ? LIMIT 1", (username,))
if cur.fetchone() is None:
conn.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(username, password_hash),
)
conn.commit()
finally:
conn.close()
# --- bikes ---
def list_bikes(conn: sqlite3.Connection) -> list[Bike]:
rows = conn.execute(
"SELECT id, name, service_interval_miles FROM bikes ORDER BY name"
).fetchall()
return [Bike(r["id"], r["name"], r["service_interval_miles"]) for r in rows]
def create_bike(conn: sqlite3.Connection, name: str, service_interval_miles: int | None) -> int:
cur = conn.execute(
"INSERT INTO bikes (name, service_interval_miles) VALUES (?, ?)",
(name, service_interval_miles),
)
conn.commit()
return int(cur.lastrowid)
def update_bike(conn: sqlite3.Connection, bike_id: int, name: str, interval: int | None) -> None:
conn.execute(
"UPDATE bikes SET name = ?, service_interval_miles = ? WHERE id = ?",
(name, interval, bike_id),
)
conn.commit()
def delete_bike(conn: sqlite3.Connection, bike_id: int) -> None:
conn.execute("DELETE FROM bikes WHERE id = ?", (bike_id,))
conn.commit()
def get_bike(conn: sqlite3.Connection, bike_id: int) -> Bike | None:
r = conn.execute(
"SELECT id, name, service_interval_miles FROM bikes WHERE id = ?", (bike_id,)
).fetchone()
return Bike(r["id"], r["name"], r["service_interval_miles"]) if r else None
# --- events ---
def create_event(
conn: sqlite3.Connection,
bike_id: int,
type: str,
event_date: date,
mileage: int | None,
cost: int | None,
notes: str | None,
) -> int:
cur = conn.execute(
"INSERT INTO events (bike_id, type, event_date, mileage, cost, notes) "
"VALUES (?, ?, ?, ?, ?, ?)",
(bike_id, type, event_date.isoformat(), mileage, cost, notes),
)
conn.commit()
return int(cur.lastrowid)
def update_event(
conn: sqlite3.Connection,
event_id: int,
type: str,
event_date: date,
mileage: int | None,
cost: int | None,
notes: str | None,
) -> None:
conn.execute(
"UPDATE events SET type = ?, event_date = ?, mileage = ?, cost = ?, notes = ? WHERE id = ?",
(type, event_date.isoformat(), mileage, cost, notes, event_id),
)
conn.commit()
def delete_event(conn: sqlite3.Connection, event_id: int) -> None:
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
conn.commit()
def _row_to_event(r: sqlite3.Row) -> BikeEvent:
return BikeEvent(
id=r["id"],
bike_id=r["bike_id"],
type=r["type"],
event_date=date.fromisoformat(r["event_date"]),
mileage=r["mileage"],
cost=r["cost"],
notes=r["notes"],
)
def list_events(conn: sqlite3.Connection, bike_id: int) -> list[BikeEvent]:
rows = conn.execute(
"SELECT * FROM events WHERE bike_id = ? ORDER BY event_date DESC, id DESC", (bike_id,)
).fetchall()
return [_row_to_event(r) for r in rows]
def latest_event_with_mileage(conn: sqlite3.Connection, bike_id: int) -> BikeEvent | None:
r = conn.execute(
"SELECT * FROM events WHERE bike_id = ? AND mileage IS NOT NULL "
"ORDER BY event_date DESC, id DESC LIMIT 1",
(bike_id,),
).fetchone()
return _row_to_event(r) if r else None
def latest_event_of_type(conn: sqlite3.Connection, bike_id: int, type: str) -> BikeEvent | None:
r = conn.execute(
"SELECT * FROM events WHERE bike_id = ? AND type = ? "
"ORDER BY event_date DESC, id DESC LIMIT 1",
(bike_id, type),
).fetchone()
return _row_to_event(r) if r else None
def sum_cost(conn: sqlite3.Connection, bike_id: int) -> int:
r = conn.execute(
"SELECT COALESCE(SUM(cost), 0) AS s FROM events WHERE bike_id = ?", (bike_id,)
).fetchone()
return int(r["s"])
def count_events(conn: sqlite3.Connection, bike_id: int) -> int:
r = conn.execute("SELECT COUNT(*) AS n FROM events WHERE bike_id = ?", (bike_id,)).fetchone()
return int(r["n"])
@contextmanager
def get_conn():
db_path = get_settings().resolved_db_path
conn = connect(db_path)
try:
yield conn
finally:
conn.close()
+508
View File
@@ -0,0 +1,508 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
from fastapi import FastAPI, Request, Response, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, field_validator
from starlette.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from . import db as d
from . import services as svc
from .config import Settings, get_settings
from .security import (
SESSION_COOKIE,
check_csrf,
client_ip,
create_session,
csrf_token,
delete_session,
get_user_id_for_session,
hash_password,
is_throttled,
record_login_attempt,
verify_password,
)
APP_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
# --- shared helpers --------------------------------------------
def _s(request: Request) -> Settings:
return get_settings()
def _redirect(location: str, status_code: int = status.HTTP_303_SEE_OTHER):
return RedirectResponse(location, status_code=status_code)
def _template_env(request: Request) -> dict:
s = get_settings()
base: dict = {
"request": request,
"event_types": s.event_types,
"today": date.today(),
"settings": s,
}
return base
def _csrf(request: Request) -> str:
s = get_settings()
return csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE))
def _parse_int(value: str | None) -> int | None:
if value is None:
return None
value = value.strip().replace(",", "")
if not value:
return None
return int(value)
def _errors_from(exc: Exception) -> dict[str, str]:
if hasattr(exc, "errors"):
return {e["loc"][-1]: e["msg"] for e in exc.errors()}
return {"_": str(exc)}
def current_user_id(request: Request) -> int | None:
token = request.cookies.get(SESSION_COOKIE)
with d.get_conn() as conn:
return get_user_id_for_session(conn, token)
def require_user(request: Request) -> int:
uid = current_user_id(request)
if uid is None:
raise FastAPIUnauthorized()
return uid
class FastAPIUnauthorized(Exception):
pass
# --- form models -------------------------------------------------
class BikeIn(BaseModel):
name: str
service_interval_miles: int | None = None
@field_validator("name")
@classmethod
def _name(cls, v: str) -> str:
v = v.strip()
if not v or len(v) > 100:
raise ValueError("Name must be 1-100 characters.")
return v
@field_validator("service_interval_miles")
@classmethod
def _interval(cls, v: int | None) -> int | None:
if v is not None and not (1 <= v <= 10_000_000):
raise ValueError("Interval must be a positive number of miles, or blank.")
return v
class EventIn(BaseModel):
type: str
event_date: date
mileage: int | None = None
cost: int | None = None
notes: str | None = None
@field_validator("type")
@classmethod
def _type(cls, v: str) -> str:
s = get_settings()
if v not in s.event_types:
raise ValueError(f"Type must be one of: {', '.join(s.event_types)}.")
return v
@field_validator("event_date")
@classmethod
def _date(cls, v: date) -> date:
if v > date.today():
raise ValueError("Date cannot be in the future.")
if v < date(1950, 1, 1):
raise ValueError("Date looks too old.")
return v
@field_validator("mileage")
@classmethod
def _mileage(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError("Mileage cannot be negative.")
return v
@field_validator("cost")
@classmethod
def _cost(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError("Cost cannot be negative.")
return v
def _form_str(form, key: str) -> str:
return str(form.get(key) or "").strip()
def _form_int(form, key: str) -> int | None:
return _parse_int(_form_str(form, key))
def _form_date(form, key: str) -> date:
return date.fromisoformat(_form_str(form, key) or date.today().isoformat())
def _form_notes(form) -> str | None:
v = _form_str(form, "notes")
return v or None
def _bike_in(form) -> BikeIn:
return BikeIn(
name=_form_str(form, "name"),
service_interval_miles=_form_int(form, "service_interval_miles"),
)
def _event_in(form) -> EventIn:
return EventIn(
type=_form_str(form, "type"),
event_date=_form_date(form, "event_date"),
mileage=_form_int(form, "mileage"),
cost=_form_int(form, "cost"),
notes=_form_notes(form),
)
# --- request context helpers (form data -> dict for re-render) ---
def _form_dict(form) -> dict:
"""Best-effort form dict for pre-fill on validation errors."""
out: dict = {}
try:
for key, value in form.items():
out[key] = value if isinstance(value, str) else str(value)
except AttributeError:
pass
return out
# --- app factory -------------------------------------------------
@asynccontextmanager
async def lifespan(_: FastAPI):
s = get_settings()
s.data_dir.mkdir(parents=True, exist_ok=True)
d.init_schema(s.resolved_db_path)
if s.password is None:
raise RuntimeError(
"BIKE_ADMIN_PASSWORD is not set. Refusing to start to avoid an "
"insecure default. Set BIKE_ADMIN_USERNAME and BIKE_ADMIN_PASSWORD "
"in the environment (or a .env file) and restart."
)
d.ensure_user(
s.resolved_db_path,
s.username,
s.password,
hash_password(s.password),
)
yield
app = FastAPI(title="bike-admin", lifespan=lifespan)
@app.exception_handler(FastAPIUnauthorized)
async def _unauthorized(request: Request, exc: FastAPIUnauthorized):
return RedirectResponse("/login", status_code=303)
app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")
templates.env.filters["money"] = lambda v: f"{v:,}" if v is not None else ""
templates.env.globals["event_types"] = get_settings().event_types
templates.env.globals["status_class"] = svc.status_class
templates.env.globals["status_text"] = svc.status_text
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
h = response.headers
h.setdefault("X-Content-Type-Options", "nosniff")
h.setdefault("X-Frame-Options", "DENY")
h.setdefault("Referrer-Policy", "same-origin")
h.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
csp = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self'; "
"font-src 'self'; "
"script-src 'self'; "
"form-action 'self'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
)
h.setdefault("Content-Security-Policy", csp)
return response
def _cookie_opts(request: Request, s: Settings) -> dict:
secure = bool(s.is_production) or (request.url is not None and request.url.scheme == "https")
return {
"path": "/",
"samesite": "lax",
"httponly": True,
"secure": secure,
"max_age": s.session_ttl_seconds,
}
def _csrf_ok(request: Request, form) -> bool:
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
return check_csrf(s.secret_key, token, _form_str(form, "csrf"))
def _login_render(request: Request, error: str | None) -> Response:
context = _template_env(request)
return templates.TemplateResponse(request, "login.html", {**context, "error": error})
def _bike_render(
request: Request, kind: str, bike, errors: dict, old: dict, action: str | None = None
) -> Response:
is_edit = kind == "edit"
if action is None:
action = request.url.path
context = _template_env(request)
context.update(
bike=bike,
errors=errors,
old=old,
csrf=_csrf(request),
action=action,
is_edit=is_edit,
)
return templates.TemplateResponse(request, f"bikes/{kind}.html", context)
# --- auth routes -------------------------------------------------
@app.get("/login", include_in_schema=False)
async def login_get(request: Request):
return _login_render(request, None)
@app.post("/login", include_in_schema=False)
async def login_post(request: Request):
s = get_settings()
form = await request.form()
username = _form_str(form, "username")
password = _form_str(form, "password")
ip = client_ip(request, s.trust_proxy)
with d.get_conn() as conn:
if is_throttled(conn, ip, s):
return _login_render(request, "Too many login attempts. Try again in a few minutes.")
cur = conn.execute(
"SELECT id, password_hash FROM users WHERE username = ?", (username,)
).fetchone()
ok = cur is not None and verify_password(password, cur["password_hash"])
if not ok:
record_login_attempt(conn, ip)
return _login_render(request, "Invalid username or password.")
token = create_session(conn, cur["id"], s)
nxt = _form_str(form, "next") or "/"
resp = RedirectResponse(nxt, status_code=303)
resp.set_cookie(SESSION_COOKIE, token, **_cookie_opts(request, s))
return resp
@app.get("/logout", include_in_schema=False)
async def logout_get(request: Request):
token = request.cookies.get(SESSION_COOKIE)
with d.get_conn() as conn:
delete_session(conn, token)
resp = _login_render(request, None)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
# --- dashboard ---------------------------------------------------
@app.get("/", include_in_schema=False)
async def dashboard(request: Request):
require_user(request)
with d.get_conn() as conn:
action = svc.action_needed(conn)
soon = svc.due_soon(conn)
bikes = svc.all_summaries(conn)
context = _template_env(request)
context.update(
action=action,
due_soon=soon,
bikes=bikes,
csrf=_csrf(request),
)
return templates.TemplateResponse(request, "dashboard.html", context)
# --- bikes -------------------------------------------------------
@app.get("/bikes/new", include_in_schema=False)
async def bike_new_get(request: Request):
require_user(request)
return _bike_render(request, "new", None, {}, {})
@app.post("/bikes/new", include_in_schema=False)
async def bike_new_post(request: Request):
require_user(request)
form = await request.form()
if not _csrf_ok(request, form):
return _bike_render(request, "new", None, {"_": "CSRF check failed."}, _form_dict(form))
try:
bike_in = _bike_in(form)
except Exception as exc:
return _bike_render(request, "new", None, _errors_from(exc), _form_dict(form))
with d.get_conn() as conn:
bike_id = d.create_bike(conn, bike_in.name, bike_in.service_interval_miles)
return _redirect(f"/bikes/{bike_id}")
@app.get("/bikes/{bike_id}", include_in_schema=False)
async def bike_detail_get(request: Request, bike_id: int):
require_user(request)
s = get_settings()
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
summary = svc.summarize_bike(conn, bike)
events = d.list_events(conn, bike.id)
context = _template_env(request)
context.update(
bike=bike,
summary=summary,
events=events,
csrf=_csrf(request),
event_types=s.event_types,
)
return templates.TemplateResponse(request, "bikes/detail.html", context)
@app.get("/bikes/{bike_id}/edit", include_in_schema=False)
async def bike_edit_get(request: Request, bike_id: int):
require_user(request)
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
return _bike_render(request, "edit", bike, {}, {})
@app.post("/bikes/{bike_id}/edit", include_in_schema=False)
async def bike_edit_post(request: Request, bike_id: int):
require_user(request)
form = await request.form()
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
if not _csrf_ok(request, form):
errs = {"_": "CSRF check failed."}
return _bike_render(request, "edit", bike, errs, _form_dict(form))
try:
bike_in = _bike_in(form)
except Exception as exc:
return _bike_render(request, "edit", bike, _errors_from(exc), _form_dict(form))
d.update_bike(conn, bike_id, bike_in.name, bike_in.service_interval_miles)
return _redirect(f"/bikes/{bike_id}")
@app.post("/bikes/{bike_id}/delete", include_in_schema=False)
async def bike_delete(request: Request, bike_id: int):
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
form = await request.form()
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
return _redirect(f"/bikes/{bike_id}")
with d.get_conn() as conn:
d.delete_bike(conn, bike_id)
return _redirect("/", status.HTTP_303_SEE_OTHER)
# --- events ------------------------------------------------------
@app.post("/bikes/{bike_id}/events", include_in_schema=False)
async def event_create(request: Request, bike_id: int):
"""Create an event; return the service card + fresh list (HTMX swap)."""
require_user(request)
s = get_settings()
form = await request.form()
if not _csrf_ok(request, form):
return _render_bike_partial(request, s, bike_id, errors={"_": "CSRF check failed."})
try:
event_in = _event_in(form)
except Exception as exc:
errors = _errors_from(exc)
return _render_bike_partial(request, s, bike_id, errors=errors, old=_form_dict(form))
with d.get_conn() as conn:
if d.get_bike(conn, bike_id) is None:
return _redirect("/")
d.create_event(
conn,
bike_id,
event_in.type,
event_in.event_date,
event_in.mileage,
event_in.cost,
event_in.notes,
)
return _render_bike_partial(request, s, bike_id)
def _render_bike_partial(
request, s: Settings, bike_id: int, errors: dict | None = None, old: dict | None = None
) -> Response:
"""Render the service card + fresh event list (HTMX swap into #bike-body)."""
base: dict = {
"request": request,
"today": date.today(),
"event_types": s.event_types,
"settings": s,
"bike_id": bike_id,
"csrf": csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE)),
}
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id) or d.Bike(bike_id, "?", None)
summary = svc.summarize_bike(conn, bike)
events = d.list_events(conn, bike.id)
base.update(bike=bike, summary=summary, events=events, old=old or {}, errors=errors or {})
return templates.TemplateResponse(request, "events/partial.html", base)
@app.post("/events/{event_id}/delete", include_in_schema=False)
async def event_delete(request: Request, event_id: int):
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
form = await request.form()
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
return _redirect("/")
with d.get_conn() as conn:
r = conn.execute("SELECT bike_id FROM events WHERE id = ?", (event_id,)).fetchone()
if r is None:
return _redirect("/")
bike_id = r["bike_id"]
d.delete_event(conn, event_id)
return _redirect(f"/bikes/{bike_id}")
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from .config import Settings
_hasher = PasswordHasher()
#: Session cookie name.
SESSION_COOKIE = "bike_session"
CSRF_COOKIE = "bike_csrf"
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
def _token() -> str:
return secrets.token_urlsafe(32)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
# --- sessions ---
def create_session(conn: sqlite3.Connection, user_id: int, settings: Settings) -> str:
token = _token()
created = _now()
expires = created + timedelta(seconds=settings.session_ttl_seconds)
conn.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(
token,
user_id,
created.isoformat(sep=" "),
expires.isoformat(sep=" "),
),
)
conn.commit()
return token
def get_user_id_for_session(conn: sqlite3.Connection, token: str | None) -> int | None:
if not token:
return None
r = conn.execute(
"SELECT user_id, expires_at FROM sessions WHERE token = ? LIMIT 1", (token,)
).fetchone()
if r is None:
return None
expires = datetime.fromisoformat(r["expires_at"])
if expires <= _now():
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
conn.commit()
return None
return int(r["user_id"])
def delete_session(conn: sqlite3.Connection, token: str | None) -> None:
if not token:
return
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
conn.commit()
# --- CSRF ---
def csrf_token(secret_key: str, session_token: str | None) -> str:
h = hmac.new(secret_key.encode(), session_token.encode(), hashlib.sha256).hexdigest()
return h[:32]
def check_csrf(secret_key: str, session_token: str | None, provided: str | None) -> bool:
if not provided:
return False
expected = csrf_token(secret_key, session_token)
return hmac.compare_digest(expected, provided)
# --- login throttling ---
def is_throttled(conn: sqlite3.Connection, ip: str, settings: Settings) -> bool:
window_start = (_now() - timedelta(seconds=settings.login_window_seconds)).isoformat(sep=" ")
r = conn.execute(
"SELECT COUNT(*) AS n FROM login_attempts WHERE ip = ? AND ts >= ?",
(ip, window_start),
).fetchone()
return int(r["n"]) >= settings.login_max_attempts
def record_login_attempt(conn: sqlite3.Connection, ip: str) -> None:
ts = _now().isoformat(sep=" ")
conn.execute("INSERT INTO login_attempts (ip, ts) VALUES (?, ?)", (ip, ts))
conn.commit()
def cleanup_old_attempts(conn: sqlite3.Connection, settings: Settings) -> None:
cutoff = (_now() - timedelta(seconds=settings.login_window_seconds * 2)).isoformat(sep=" ")
conn.execute("DELETE FROM login_attempts WHERE ts < ?", (cutoff,))
conn.commit()
def client_ip(request, trust_proxy: bool) -> str:
"""Best-effort remote IP. Behind a proxy, trust the first X-Forwarded-For hop."""
if trust_proxy:
ffor = request.headers.get("x-forwarded-for")
if ffor:
return ffor.split(",")[0].strip()
if request.client is not None:
return request.client.host or "unknown"
return "unknown"
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from . import db
from .config import get_settings
@dataclass
class ServiceDue:
"""Service-due snapshot for one bike.
status:
"no-data" -> cannot compute (missing current mileage or last service,
or no interval set)
"ok" -> still under the interval
"due-soon" -> within a short lead time of the interval - book it soon
"overdue" -> past the interval - book a service now
"""
current_mileage: int | None
last_service: db.BikeEvent | None
miles_since: int | None
miles_until_due: int | None # negative when overdue
status: str
def compute_service_due(conn: sqlite3.Connection, bike: db.Bike) -> ServiceDue:
# 'current_mileage' is derived from the most recent event that recorded a
# mileage, i.e. the bike's latest known odometer reading.
current_evt = db.latest_event_with_mileage(conn, bike.id)
current_mileage = current_evt.mileage if current_evt else None
# The 'last service' is the most recent event typed 'service' that ALSO has
# a mileage recorded, so we can compute a delta.
last_service = db.latest_event_of_type(conn, bike.id, "service")
if last_service is not None and last_service.mileage is None:
last_service = None
no_data = ServiceDue(current_mileage, last_service, None, None, "no-data")
if current_mileage is None or last_service is None or bike.service_interval_miles is None:
return no_data
miles_since = current_mileage - last_service.mileage
interval = bike.service_interval_miles
miles_until_due = interval - miles_since
if miles_since > interval:
status = "overdue"
elif miles_until_due <= get_settings().service_due_soon_miles:
status = "due-soon"
else:
status = "ok"
return ServiceDue(
current_mileage=current_mileage,
last_service=last_service,
miles_since=miles_since,
miles_until_due=miles_until_due,
status=status,
)
@dataclass
class BikeSummary:
bike: db.Bike
due: ServiceDue
event_count: int
total_cost: int
def summarize_bike(conn: sqlite3.Connection, bike: db.Bike) -> BikeSummary:
return BikeSummary(
bike=bike,
due=compute_service_due(conn, bike),
event_count=db.count_events(conn, bike.id),
total_cost=db.sum_cost(conn, bike.id),
)
def all_summaries(conn: sqlite3.Connection) -> list[BikeSummary]:
"""All bikes with summaries; bikes needing attention first."""
summaries = [summarize_bike(conn, b) for b in db.list_bikes(conn)]
rank = {"overdue": 0, "due-soon": 1, "ok": 2, "no-data": 3}
summaries.sort(key=lambda s: (rank.get(s.due.status, 4), s.bike.name))
return summaries
def action_needed(conn: sqlite3.Connection) -> list[BikeSummary]:
"""Bikes past their service interval - book it now."""
return [s for s in all_summaries(conn) if s.due.status == "overdue"]
def due_soon(conn: sqlite3.Connection) -> list[BikeSummary]:
"""Bikes within the lead time of the interval - book it soon."""
return [s for s in all_summaries(conn) if s.due.status == "due-soon"]
def status_class(due: ServiceDue) -> str:
return {
"overdue": "badge-overdue",
"due-soon": "badge-due-soon",
"ok": "badge-ok",
"no-data": "badge-muted",
}[due.status]
def status_text(due: ServiceDue) -> str:
if due.status == "no-data":
return "no service logged"
if due.status == "due-soon":
return f"book soon - {due.miles_until_due:,} mi left"
if due.status == "ok":
return f"due in {due.miles_until_due:,} mi"
return f"overdue by {abs(due.miles_until_due):,} mi"
+1
View File
File diff suppressed because one or more lines are too long
+193
View File
@@ -0,0 +1,193 @@
:root {
--bg: #0f1115;
--card: #1a1d23;
--card-inset: #12151b;
--ink: #e6e9ee;
--muted: #8b94a1;
--line: #2a2f37;
--accent: #4c8dff;
--accent-ink: #0b0d10;
--ok-bg: #12231a;
--ok-ink: #4ade80;
--warn-bg: #2a2113;
--warn-ink: #fbbf24;
--overdue-bg: #2a1416;
--overdue-ink: #f87171;
--radius: 12px;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 1px 3px rgba(0,0,0,.4);
max-width: 100%;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, system-ui, sans-serif;
color: var(--ink);
background: var(--bg);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
h1, h2, h3 { line-height: 1.2; font-weight: 600; }
.muted { color: var(--muted); font-size: .9rem; }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 10;
}
.brand { font-weight: 700; color: var(--ink); }
.brand:hover { text-decoration: none; color: var(--accent); }
.nav { display: flex; gap: .5rem; align-items: center; }
.container { max-width: 960px; margin: 0 auto; padding: 1.25rem; }
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem;
}
.page-actions { display: flex; gap: .5rem; align-items: center; }
/* cards */
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1rem;
}
.bike-card { display: block; color: inherit; }
.bike-card:hover { border-color: var(--accent); text-decoration: none; }
.bike-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
.bike-name { font-size: 1.05rem; font-weight: 600; }
.empty { text-align: center; padding: 2rem 1rem; }
.empty .btn { margin-top: 1rem; }
/* callouts */
.callout { padding: .75rem 1rem; border-radius: var(--radius); margin-bottom: 1rem; display: flex; flex-wrap: wrap; gap: .25rem .75rem; align-items: center; }
.callout-warn { background: var(--warn-bg); color: var(--warn-ink); border: 1px solid #5a4718; }
.callout-danger { background: var(--overdue-bg); color: var(--overdue-ink); border: 1px solid #5a2528; }
.callout a { color: inherit; font-weight: 600; }
.inline-item { display: inline-flex; gap: .4rem; align-items: center; }
/* service card */
.due-top { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; margin-bottom: .75rem; }
.due-title { margin: 0; }
.due-summary { margin: .15rem 0 0; color: var(--muted); font-size: .95rem; }
.due-meta { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .4rem .75rem; margin: 0; }
.due-meta dt { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; }
.due-meta dd { margin: 0; font-weight: 600; }
.due-card.badge-ok { border-left: 4px solid var(--ok-ink); }
.due-card.badge-due-soon { border-left: 4px solid var(--warn-ink); }
.due-card.badge-overdue { border-left: 4px solid var(--overdue-ink); }
.due-card.badge-muted { border-left: 4px solid var(--muted); }
/* badges */
.badge {
display: inline-flex; align-items: center;
padding: .15rem .55rem; border-radius: 999px;
font-size: .8rem; font-weight: 600; white-space: nowrap;
}
.badge-ok { background: var(--ok-bg); color: var(--ok-ink); }
.badge-due-soon { background: var(--warn-bg); color: var(--warn-ink); }
.badge-overdue { background: var(--overdue-bg); color: var(--overdue-ink); }
.badge-muted { background: var(--card-inset); color: var(--muted); }
/* buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
padding: .5rem .9rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--card); color: var(--ink);
font: inherit; font-weight: 500; cursor: pointer;
}
.btn:hover { background: var(--card-inset); text-decoration: none; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn-primary:hover { background: #3f7fe6; }
.btn-danger { border-color: #5a2528; color: var(--overdue-ink); background: var(--card); }
.btn-danger:hover { background: var(--overdue-bg); }
.btn-link { background: none; border: none; padding: .1rem; }
.btn-block { width: 100%; }
.btn-busy { display: none; }
.btn.htmx-request .btn-busy { display: inline; }
.btn.htmx-request .btn-label { display: none; }
/* forms */
form label { display: flex; flex-direction: column; gap: .3rem; margin: 0.65rem 0; }
form label > span { font-weight: 500; font-size: .9rem; }
input, select, textarea {
font: inherit; color: inherit;
padding: .5rem .6rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--card-inset);
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);
}
.form-row { display: grid; gap: .75rem; }
@media (min-width: 560px) { .form-row { grid-template-columns: 1fr 1fr; } }
.form-actions { margin-top: 1rem; display: flex; align-items: center; gap: .75rem; }
.add-note { margin: 0; font-size: .8rem; color: var(--muted); max-width: 320px; }
/* events table */
.events-table { width: 100%; border-collapse: collapse; }
.events-table th, .events-table td { text-align: left; padding: .5rem .4rem; vertical-align: top; }
.events-table th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid var(--line); }
.events-table tr + tr td { border-top: 1px solid var(--line); }
.noted { color: var(--muted); }
.events-table td.notes { max-width: 220px; overflow-wrap: anywhere; }
.row-actions { text-align: right; }
.row-actions form { display: inline; }
.tag { display: inline-block; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 500; }
.tag-service { background: #16263f; color: #7fb0ff; }
.tag-fuel { background: #14291d; color: #7fcfa0; }
.tag-tyre { background: #332713; color: #e0b06a; }
.tag-chain { background: #241a3a; color: #b39ddb; }
.tag-brake { background: #331a1a; color: #f0908c; }
.tag-repair { background: #14291d; color: #7fcfa0; }
.tag-other { background: #1c2129; color: #9aa5b1; }
/* alert */
.alert { padding: .6rem .8rem; border-radius: 8px; margin: .75rem 0; }
.alert-error { background: var(--overdue-bg); color: var(--overdue-ink); }
/* danger zone */
.danger-zone { margin-top: 1rem; padding: 1rem; border: 1px dashed var(--line); border-radius: var(--radius); }
/* auth */
body.auth { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--bg); }
.auth-card { max-width: 380px; width: 100%; padding: 2rem 1.5rem; margin: 0 auto; }
/* toast */
.toast {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
background: #2a2f37; color: var(--ink); padding: .5rem .9rem; border-radius: 8px;
font-size: .9rem; animation: fade-in .2s ease; z-index: 20;
}
@keyframes fade-in { from { opacity: 0; transform: translate(-50%, .5rem);} to { opacity: 1; transform: translate(-50%, 0);} }
.busy { display: none; }
/* busy indicator (htmx sets .htmx-request on the [hx-indicator] target) */
.spinner { display: none; width: 1em; height: 1em; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
.htmx-request ~ .spinner, .htmx-request .spinner { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}bike-admin{% endblock %}</title>
<link rel="stylesheet" href="/static/main.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">bike-admin</a>
<nav class="nav">
<a href="/bikes/new" class="btn btn-primary">+ Bike</a>
<a href="/logout"
class="btn"
onclick="if(!confirm('Log out?')){event.preventDefault();return false;}">Log&nbsp;out</a>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ bike.name }} - bike-admin{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ bike.name }}</h1>
<a href="/bikes/{{ bike.id }}/edit" class="btn">Edit</a>
</div>
<div id="bike-body">
{% include "components/service_card.html" with context %}
<section class="card list-card">
{% include "events/list.html" with context %}
</section>
</div>
<section class="card">
<h2>Log a new event</h2>
<form method="post" action="/bikes/{{ bike.id }}/events" novalidate
hx-post="/bikes/{{ bike.id }}/events"
hx-target="#bike-body"
hx-swap="innerHTML"
hx-indicator="#busy-btn"
hx-on::after-request="this.reset()">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="form-row">
<label>
<span>Type</span>
<select name="type" required>
{% for t in event_types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Date</span>
<input type="date" name="event_date" required max="{{ today.isoformat() }}"
value="{{ today.isoformat() }}">
</label>
</div>
<div class="form-row">
<label>
<span>Mileage (optional)</span>
<input type="number" name="mileage" min="0" step="1"
{% if summary.due.current_mileage is not none %}value="{{ summary.due.current_mileage }}"{% endif %}>
<small class="muted">Odometer reading at this event{% if summary.due.current_mileage is not none %}. Prefilled with your last reading of {{ summary.due.current_mileage | money }} mi; just nudge it up{% endif %}.</small>
</label>
<label>
<span>Cost (optional)</span>
<input type="number" name="cost" min="0" step="1">
</label>
</div>
<label>
<span>Notes</span>
<textarea name="notes" rows="2"></textarea>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="busy-btn">
<span class="btn-label">Add event</span>
<span class="btn-busy">Adding&hellip;</span>
</button>
<p class="add-note">
Tip: logging a <em>service</em> with the current mileage keeps your due
date accurate.
</p>
</div>
</form>
</section>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Edit bike - bike-admin{% endblock %}
{% block content %}
<div class="page-head"><h1>Edit {{ bike.name }}</h1></div>
<div class="card">
{% include "components/bike_form.html" with context %}
</div>
<div class="danger-zone">
<h2 class="muted">Danger zone</h2>
<form method="post" action="/bikes/{{ bike.id }}/delete"
onsubmit="if(!confirm('Delete this bike and all its events? This cannot be undone.'))return false;">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-danger" type="submit">Delete bike</button>
</form>
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}New bike - bike-admin{% endblock %}
{% block content %}
<div class="page-head"><h1>New bike</h1></div>
<div class="card">
{% include "components/bike_form.html" with context %}
</div>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{# Shared bike form. Expects: action (url), csrf, old (dict), errors (dict), is_edit, bike #}
<form method="post" action="{{ action }}" novalidate>
<input type="hidden" name="csrf" value="{{ csrf }}">
{% if errors %}
<div class="alert alert-error" role="alert">
{% for key, val in errors.items() %}
<div>{{ val }} {{ ' ' if not loop.last else '' }}</div>
{% endfor %}
</div>
{% endif %}
<label>
<span>Name</span>
<input type="text" name="name" value="{{ old.get('name', bike.name if bike else '') }}" required>
</label>
<label>
<span>Service interval (miles)</span>
<input type="number" name="service_interval_miles" min="1" step="1"
value="{{ old.get('service_interval_miles', bike.service_interval_miles if bike else '') }}">
<small class="muted">How often this bike needs a full service, in miles.</small>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save changes' if is_edit else 'Add bike' }}</button>
<a href="javascript:history.back()" class="btn">Cancel</a>
</div>
</form>
@@ -0,0 +1,23 @@
{# Service-status card. Expects: bike, summary (BikeSummary). #}
<div class="card due-card {{ status_class(summary.due) }}">
<div class="due-top">
<div>
<h2 class="due-title">Service status</h2>
<p class="due-summary">{{ status_text(summary.due) }}</p>
</div>
<span class="badge {{ status_class(summary.due) }}">{{ status_text(summary.due) }}</span>
</div>
<dl class="due-meta">
{% if summary.due.current_mileage is not none %}
<dt>Odometer</dt><dd>{{ summary.due.current_mileage | money }} mi</dd>
{% endif %}
{% if summary.due.last_service %}
{% set ls = summary.due.last_service %}
<dt>Last service</dt><dd>{{ ls.event_date }} &middot; {{ ls.mileage | money }} mi</dd>
{% endif %}
<dt>Interval</dt>
<dd>{% if bike.service_interval_miles %}{{ bike.service_interval_miles | money }} mi{% else %}<span class="muted">not set</span>{% endif %}</dd>
<dt>Records</dt><dd>{{ summary.event_count }}</dd>
<dt>Cost to date</dt><dd>{{ summary.total_cost | money }}</dd>
</dl>
</div>
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Dashboard - bike-admin{% endblock %}
{% block content %}
<div class="page-head">
<h1>Your bikes</h1>
<a href="/bikes/new" class="btn btn-primary">+ New bike</a>
</div>
{% if action %}
<div class="callout callout-danger" role="alert">
<strong>Book a service now:</strong>
{% for s in action %}
<span class="inline-item"><a href="/bikes/{{ s.bike.id }}">{{ s.bike.name }}</a> <span class="badge badge-overdue">{{ status_text(s.due) }}</span></span>
{% endfor %}
</div>
{% endif %}
{% if due_soon %}
<div class="callout callout-warn" role="alert">
<strong>Service coming up:</strong>
{% for s in due_soon %}
<span class="inline-item"><a href="/bikes/{{ s.bike.id }}">{{ s.bike.name }}</a> <span class="badge badge-due-soon">{{ status_text(s.due) }}</span></span>
{% endfor %}
</div>
{% endif %}
{% if not bikes %}
<div class="card empty">
<p>No bikes yet. Add your first bike to start logging services and repairs.</p>
<a href="/bikes/new" class="btn btn-primary">Add a bike</a>
</div>
{% else %}
<div class="grid">
{% for s in bikes %}
<a class="card bike-card {{ status_class(s.due) }}-card" href="/bikes/{{ s.bike.id }}">
<div class="bike-head">
<h2 class="bike-name">{{ s.bike.name }}</h2>
<span class="badge {{ status_class(s.due) }}">{{ status_text(s.due) }}</span>
</div>
<dl class="bike-meta">
{% if s.due.current_mileage is not none %}
<dt>Odometer</dt><dd>{{ s.due.current_mileage | money }} mi</dd>
{% endif %}
{% if s.due.last_service %}
<dt>Last service</dt><dd>{{ s.due.last_service.event_date }} ({{ s.due.last_service.mileage | money }} mi)</dd>
{% endif %}
{% if s.bike.service_interval_miles %}
<dt>Every</dt><dd>{{ s.bike.service_interval_miles | money }} mi</dd>
{% endif %}
<dt>Records</dt><dd>{{ s.event_count }}</dd>
<dt>Cost to date</dt><dd>{{ s.total_cost | money }}</dd>
</dl>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{# Expects: events, csrf, old, errors (all optional) #}
{% if events %}
<table class="events-table">
<thead>
<tr><th>When</th><th>Type</th><th>Miles</th><th>Cost</th><th>Notes</th><th></th></tr>
</thead>
<tbody>
{% for ev in events %}
<tr>
<td>{{ ev.event_date }}</td>
<td><span class="tag tag-{{ ev.type }}">{{ ev.type }}</span></td>
<td>{% if ev.mileage is not none %}{{ ev.mileage | money }}{% else %}-{% endif %}</td>
<td>{{ ev.cost | money if ev.cost is not none else '-' }}</td>
<td class="notes">{{ ev.notes or '' }}</td>
<td class="row-actions">
<form method="post" action="/events/{{ ev.id }}/delete"
onsubmit="if(!confirm('Delete this event?'))return false;">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-link btn-danger" type="submit" aria-label="Delete event">&times;</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No events yet. Log your first one below.</p>
{% endif %}
{% if errors %}
<div class="alert alert-error" style="margin-top:1rem">
{% for key, val in errors.items() %}<div>{{ val }}</div>{% endfor %}
</div>
{% endif %}
+11
View File
@@ -0,0 +1,11 @@
{# HTMX swap target for POST /bikes/{id}/events (swaps into #bike-body).
Expects: bike, summary, events, old, errors, csrf, event_types. #}
{% include "components/service_card.html" with context %}
<section class="card list-card">
{% include "events/list.html" with context %}
</section>
{% if errors %}
<div id="form-errors" class="alert alert-error" role="alert">
{% for key, val in errors.items() %}<div>{{ val }}</div>{% endfor %}
</div>
{% endif %}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in - bike-admin</title>
<link rel="stylesheet" href="/static/main.css">
</head>
<body class="auth">
<main class="container auth-card">
<h1 style="margin-top:0">bike-admin</h1>
<p class="muted">Log in to manage your bikes.</p>
{% if error %}
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %}
<form method="post" action="/login" novalidate>
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<input type="hidden" name="next" value="/">
<button type="submit" class="btn btn-primary btn-block">Log in</button>
</form>
</main>
</body>
</html>