Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,13 @@ class WorkPackage(Base):
|
||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||
type: Mapped[str] = mapped_column(String(120), default="")
|
||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||
# The accountable owner (a user id), for "My Work Packages" + assignment
|
||||
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Archived packages are hidden from the default lists/dashboard but kept for
|
||||
# the record (years-long projects accumulate hundreds of closed WPs).
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -102,7 +108,9 @@ class WorkPackage(Base):
|
||||
return {
|
||||
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
|
||||
"issued_at": _iso(self.issued_at),
|
||||
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
@@ -127,6 +135,12 @@ class User(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||
# change). The value is embedded in the JWT and re-checked on every request.
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
@@ -176,5 +190,74 @@ class Comment(Base):
|
||||
}
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only history: who changed what, when. Rows are written inside the
|
||||
same transaction as the change they describe (see server/app.py: log_event),
|
||||
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
||||
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
||||
|
||||
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
||||
of the thing it describes (you still want "who deleted WP01, and when")."""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
||||
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
||||
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
||||
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
||||
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
||||
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
||||
}
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||
the SMTP password are NOT stored here — they come from the environment."""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||
is written when something notable happens (e.g. a WP assignment); the email
|
||||
sender processes it only when email notifications are enabled AND SMTP is set —
|
||||
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
||||
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(300), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
link: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
||||
error: Mapped[str] = mapped_column(String(400), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
||||
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
||||
"status": self.status, "error": self.error,
|
||||
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
||||
}
|
||||
|
||||
|
||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
Reference in New Issue
Block a user