phase A: user accounts + session-cookie auth
Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a155ef157
commit
480fd311c5
12 changed files with 644 additions and 21 deletions
40
alembic/versions/0007_users.py
Normal file
40
alembic/versions/0007_users.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""users table — accounts, password hashing (argon2), tier, settings
|
||||
|
||||
Phase A of the multi-user migration. Adds the table but doesn't add owner
|
||||
FKs to existing rows yet — that's phase C. Until then, data is still
|
||||
effectively shared across the (small) set of authenticated accounts.
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0007"
|
||||
down_revision: Union[str, None] = "0006"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("tier", sa.String(16), nullable=False, server_default="free"),
|
||||
# Not enforced in phase A — wired up in phase E.
|
||||
sa.Column("email_verified", sa.Boolean, nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("settings_json", sa.JSON), # per-user tone/analysis/anchor/...
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True)),
|
||||
sa.UniqueConstraint("email", name="uq_users_email"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
150
app/auth.py
150
app/auth.py
|
|
@ -1,31 +1,139 @@
|
|||
"""Bearer-token auth — single static token from CASSANDRA_TOKEN env.
|
||||
If the env is empty, the app runs open (LAN-only / dev mode).
|
||||
Constant-time comparison via secrets.compare_digest.
|
||||
"""Request-level authentication.
|
||||
|
||||
Two paths accepted:
|
||||
|
||||
1. **Session cookie** (`cassandra_session`) — set by /login. Signed with
|
||||
`CASSANDRA_SESSION_SECRET` via itsdangerous; carries just the user id.
|
||||
On each request we deserialise, then load the User from the DB so the
|
||||
tier value is always fresh.
|
||||
|
||||
2. **Bearer token** (`Authorization: Bearer …`) — the legacy single-user
|
||||
path kept as an admin/dev escape hatch and for programmatic API access
|
||||
(CLI, curl, scripts). Matches `CASSANDRA_TOKEN` if set.
|
||||
|
||||
If neither matches:
|
||||
- HTML requests get 303 → /login
|
||||
- API / curl-style requests get 401
|
||||
|
||||
For backwards-compat, `require_token` is an alias for `require_auth` so
|
||||
existing routers that do `dependencies=[Depends(require_token)]` keep
|
||||
working without edit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from fastapi import Header, HTTPException, Request, status
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import get_session_factory
|
||||
from app.models import User
|
||||
from app.services.auth_service import get_user
|
||||
|
||||
|
||||
async def require_token(
|
||||
SESSION_COOKIE_NAME = "cassandra_session"
|
||||
SESSION_TTL_SECONDS = 14 * 24 * 60 * 60 # 14 days
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentUser:
|
||||
"""The authenticated principal for the current request.
|
||||
|
||||
`user` is None when the bearer token was used (admin/dev path with no
|
||||
matching DB row). Routes that need per-user scoping should check
|
||||
`is_admin` and either use a sentinel admin scope or 403."""
|
||||
is_admin: bool
|
||||
user: User | None
|
||||
|
||||
@property
|
||||
def id(self) -> int | None:
|
||||
return self.user.id if self.user else None
|
||||
|
||||
@property
|
||||
def email(self) -> str | None:
|
||||
return self.user.email if self.user else None
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
s = get_settings()
|
||||
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
|
||||
return URLSafeTimedSerializer(secret, salt="cassandra-session-v1")
|
||||
|
||||
|
||||
def sign_session(user_id: int) -> str:
|
||||
return _serializer().dumps({"uid": int(user_id)})
|
||||
|
||||
|
||||
def verify_session(cookie: str) -> int | None:
|
||||
try:
|
||||
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
|
||||
return int(data["uid"])
|
||||
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _wants_html(request: Request) -> bool:
|
||||
accept = request.headers.get("accept", "").lower()
|
||||
# Treat a missing Accept header as HTML for browser navigations.
|
||||
if not accept:
|
||||
return True
|
||||
return "text/html" in accept and "application/json" not in accept
|
||||
|
||||
|
||||
async def require_auth(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> None:
|
||||
expected = get_settings().CASSANDRA_TOKEN
|
||||
if not expected:
|
||||
return # open mode — no auth required
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Bearer token required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
provided = authorization.split(" ", 1)[1].strip()
|
||||
if not secrets.compare_digest(provided.encode(), expected.encode()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid token",
|
||||
)
|
||||
) -> CurrentUser:
|
||||
"""Resolve the current authenticated principal. Raises HTTPException
|
||||
on failure (303 redirect to /login for HTML, 401 for API)."""
|
||||
s = get_settings()
|
||||
|
||||
# --- 1) Bearer token (admin / dev / scripts) ---
|
||||
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
|
||||
provided = authorization.split(" ", 1)[1].strip()
|
||||
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
|
||||
principal = CurrentUser(is_admin=True, user=None)
|
||||
request.state.current_user = principal
|
||||
return principal
|
||||
|
||||
# --- 2) Session cookie (browser) ---
|
||||
cookie = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if cookie:
|
||||
uid = verify_session(cookie)
|
||||
if uid is not None:
|
||||
async with get_session_factory()() as db_session:
|
||||
user = await get_user(db_session, uid)
|
||||
if user is not None:
|
||||
principal = CurrentUser(is_admin=False, user=user)
|
||||
request.state.current_user = principal
|
||||
return principal
|
||||
|
||||
# --- 3) Unauthenticated ---
|
||||
if _wants_html(request):
|
||||
# Preserve the originally-requested path so /login can redirect back.
|
||||
path = request.url.path
|
||||
if request.url.query:
|
||||
path += "?" + request.url.query
|
||||
return _raise_redirect_to_login(next_path=path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _raise_redirect_to_login(next_path: str = "/") -> None:
|
||||
# Some pages (login itself) are paths a redirect loop would be silly
|
||||
# to send back to. The auth router opts out of this dependency
|
||||
# entirely, so we don't need to filter here.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
detail="Login required",
|
||||
headers={"Location": f"/login?next={next_path}"},
|
||||
)
|
||||
|
||||
|
||||
# Backwards compatibility: every existing router uses Depends(require_token).
|
||||
require_token = require_auth
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ class Settings(BaseSettings):
|
|||
# App
|
||||
CASSANDRA_TOKEN: str = ""
|
||||
CASSANDRA_PORT: int = 8000
|
||||
# Signing key for session cookies. Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
|
||||
CASSANDRA_SESSION_SECRET: str = ""
|
||||
# Set to false (or 0/no) to disable /signup after the first account is
|
||||
# created. Phase A leaves this open so the operator can self-onboard.
|
||||
CASSANDRA_SIGNUP_ENABLED: bool = True
|
||||
CASSANDRA_BASE_CURRENCY: str = "GBP"
|
||||
CASSANDRA_ANCHOR_DATE: str = ""
|
||||
CASSANDRA_MOCK: bool = False
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.config import get_settings
|
|||
from app.db import get_session_factory
|
||||
from app.logging import configure_logging, get_logger
|
||||
from app.routers import api as api_router
|
||||
from app.routers import auth as auth_router
|
||||
from app.routers import pages as pages_router
|
||||
from app.services.feeds_bootstrap import bootstrap_feeds
|
||||
|
||||
|
|
@ -65,5 +66,6 @@ app.mount(
|
|||
name="static",
|
||||
)
|
||||
|
||||
app.include_router(auth_router.router, tags=["auth"])
|
||||
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
||||
app.include_router(pages_router.router, tags=["pages"])
|
||||
|
|
|
|||
|
|
@ -187,6 +187,23 @@ class Position(Base):
|
|||
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A multi-user account. Phase A wires login + session cookies; phase C
|
||||
adds owner_user_id FKs across portfolios/snapshots/positions so data
|
||||
becomes properly tenant-scoped."""
|
||||
__tablename__ = "users"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
||||
|
||||
|
||||
class InstrumentMap(Base):
|
||||
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
|
||||
refresh prices via Yahoo after a user uploads a T212 pie CSV.
|
||||
|
|
|
|||
131
app/routers/auth.py
Normal file
131
app/routers/auth.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Authentication routes: /login, /signup, /logout.
|
||||
|
||||
These do NOT depend on require_auth (they're how you become authenticated).
|
||||
The router is included separately in app/main.py without a router-level
|
||||
auth dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.services.auth_service import AuthError, authenticate, create_user
|
||||
from app.templates_env import templates
|
||||
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
def _safe_next(next_value: str | None) -> str:
|
||||
"""Only allow same-origin relative paths to prevent open-redirect."""
|
||||
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
|
||||
return "/"
|
||||
# Block any embedded scheme or host.
|
||||
if urlparse(next_value).netloc:
|
||||
return "/"
|
||||
return next_value
|
||||
|
||||
|
||||
def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=sign_session(user_id),
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
# `secure=True` requires HTTPS; the operator should enable this in
|
||||
# production via a reverse proxy. Off for local-dev convenience.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str | None = None, error: str | None = None):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next), "error": error,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str | None = Form(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
user = await authenticate(session, email, password)
|
||||
except AuthError as e:
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next), "error": str(e),
|
||||
"email": email,
|
||||
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||
status_code=400,
|
||||
)
|
||||
target = _safe_next(next)
|
||||
resp = RedirectResponse(url=target, status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/signup", response_class=HTMLResponse)
|
||||
async def signup_page(request: Request, error: str | None = None):
|
||||
s = get_settings()
|
||||
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.",
|
||||
"signup_enabled": False},
|
||||
status_code=403,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "signup.html",
|
||||
{"error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
user = await create_user(session, email, password)
|
||||
except AuthError as e:
|
||||
return templates.TemplateResponse(
|
||||
request, "signup.html",
|
||||
{"error": str(e), "email": email},
|
||||
status_code=400,
|
||||
)
|
||||
resp = RedirectResponse(url="/", status_code=303)
|
||||
_set_session_cookie(resp, user.id)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
resp = RedirectResponse(url="/login", status_code=303)
|
||||
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout_get(request: Request):
|
||||
# Convenience for users who click a logout link rather than POSTing.
|
||||
return await logout(request)
|
||||
130
app/services/auth_service.py
Normal file
130
app/services/auth_service.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""User authentication primitives: password hashing, signup, login.
|
||||
|
||||
Argon2id for password hashing (argon2-cffi). itsdangerous for signed
|
||||
session cookies. Tier-aware user creation; phase D adds the actual
|
||||
tier-based feature gating.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, InvalidHashError
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import utcnow
|
||||
from app.models import User
|
||||
|
||||
|
||||
# Argon2 default parameters are sensible; we let it pick.
|
||||
_HASHER = PasswordHasher()
|
||||
|
||||
# Reasonable floor. Real password policy lives in Phase E.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 256
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Raised when signup/login validation fails. The message is safe to
|
||||
surface to the user as-is."""
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return _HASHER.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
_HASHER.verify(hashed, plain)
|
||||
return True
|
||||
except (VerifyMismatchError, InvalidHashError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_email_or_raise(email: str) -> str:
|
||||
try:
|
||||
info = validate_email(email, check_deliverability=False)
|
||||
return info.normalized
|
||||
except EmailNotValidError as e:
|
||||
raise AuthError(f"Invalid email: {e}")
|
||||
|
||||
|
||||
def _validate_password_or_raise(password: str) -> None:
|
||||
if not isinstance(password, str):
|
||||
raise AuthError("Password must be a string")
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise AuthError(
|
||||
f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||
)
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
raise AuthError("Password too long")
|
||||
|
||||
|
||||
async def create_user(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
*,
|
||||
tier: str = "free",
|
||||
) -> User:
|
||||
"""Create a new user. Raises AuthError on bad input or duplicate email."""
|
||||
email = _validate_email_or_raise(email).lower()
|
||||
_validate_password_or_raise(password)
|
||||
|
||||
existing = (await session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
if existing:
|
||||
raise AuthError("An account with this email already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
tier=tier,
|
||||
email_verified=False, # phase E enforces verification
|
||||
settings_json={},
|
||||
created_at=utcnow(),
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> User:
|
||||
"""Return the User if credentials match. Raises AuthError on miss.
|
||||
|
||||
Uses the same generic message for unknown-email and wrong-password to
|
||||
avoid a username-enumeration oracle."""
|
||||
email = email.strip().lower()
|
||||
user = (await session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Always run a hash verification even on unknown-email to keep timing
|
||||
# similar (mitigates timing-based user enumeration).
|
||||
if user is None:
|
||||
verify_password(password, "$argon2id$v=19$m=65536,t=3,p=4$" + "a" * 22 + "$" + "b" * 43)
|
||||
raise AuthError("Invalid email or password")
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise AuthError("Invalid email or password")
|
||||
|
||||
user.last_login_at = utcnow()
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: int) -> User | None:
|
||||
return (await session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
|
|
@ -591,6 +591,104 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
|
||||
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
|
||||
|
||||
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
|
||||
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
padding: 20px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 28px 26px;
|
||||
}
|
||||
.auth-card__brand {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
.auth-card__brand::before { content: "▰ "; opacity: 0.6; }
|
||||
.auth-card__hint {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 2px 0 18px;
|
||||
}
|
||||
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.auth-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
gap: 4px;
|
||||
}
|
||||
.auth-card input[type="email"], .auth-card input[type="password"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
.auth-card input:focus { border-color: var(--accent); }
|
||||
.auth-card button {
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 9px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-card button:hover { background: var(--accent); color: var(--bg); }
|
||||
.auth-card__alt {
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.auth-error {
|
||||
border-left: 3px solid var(--negative);
|
||||
background: color-mix(in srgb, var(--negative) 6%, transparent);
|
||||
color: var(--negative);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* User chip in header */
|
||||
.user-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
margin-left: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.user-chip a {
|
||||
color: var(--muted);
|
||||
border-bottom: 1px dotted var(--muted);
|
||||
}
|
||||
.user-chip a:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* --- Upload page (drag-drop CSV) ------------------------------------- */
|
||||
|
||||
.dz {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@
|
|||
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
||||
<span class="theme-toggle__label"></span>
|
||||
</button>
|
||||
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
||||
{% if cu and cu.user %}
|
||||
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
|
||||
{% elif cu and cu.is_admin %}
|
||||
<span class="user-chip">admin · <a href="/logout">logout</a></span>
|
||||
{% endif %}
|
||||
<span class="meta">v0.1 · UTC</span>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
42
app/templates/login.html
Normal file
42
app/templates/login.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cassandra · Login</title>
|
||||
<script>
|
||||
(function() {
|
||||
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">log in to access the dashboard</div>
|
||||
|
||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="/login" autocomplete="on">
|
||||
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
{% if signup_enabled %}
|
||||
<div class="auth-card__alt">
|
||||
No account? <a href="/signup">Create one →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
39
app/templates/signup.html
Normal file
39
app/templates/signup.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cassandra · Sign up</title>
|
||||
<script>
|
||||
(function() {
|
||||
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">create an account</div>
|
||||
|
||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="/signup" autocomplete="on">
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||
</label>
|
||||
<label>Password (min 8 characters)
|
||||
<input type="password" name="password" minlength="8" required>
|
||||
</label>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__alt">
|
||||
Already have an account? <a href="/login">Sign in →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -17,6 +17,9 @@ dependencies = [
|
|||
"apscheduler>=3.10",
|
||||
"tenacity>=9.0",
|
||||
"structlog>=24.4",
|
||||
"argon2-cffi>=23.1",
|
||||
"itsdangerous>=2.2",
|
||||
"email-validator>=2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue