This commit is contained in:
patsy 2026-08-08 04:14:25 +02:00
parent 543f135ca0
commit 17d42c52a7
18 changed files with 420 additions and 11 deletions

View file

@ -1,3 +1,4 @@
STEADY_ENV=development
SECRET_KEY=replace-with-a-long-random-value
# DATABASE_URL may override the default absolute instance/steady.db location.
FEATURE_ADMIN=false

View file

@ -62,3 +62,14 @@ color mode; enable a dyslexia-friendly local reading style; turn completion
sound on or off; and save a future reminder-frequency preference. The browser
reminder control checks capability only. It does not request notification
permission, register a service worker, or schedule reminders.
## Roles and admin placeholder
Steady recognizes `user`, `admin`, `viewer`, and `coach` roles. Viewers can read
and export their own tasks but cannot mutate task data; the other three roles
retain writes to their own tasks. Ownership checks apply independently of role.
The admin feature is absent by default. Setting `FEATURE_ADMIN=true` before the
application starts registers `/admin/`, which remains restricted to the `admin`
role. It is a data-free placeholder only: user management, role assignment, and
analytics are intentionally not implemented.

View file

@ -1,5 +1,7 @@
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from flask import Flask
@ -8,7 +10,10 @@ from config import CONFIGS
from .extensions import csrf, db, login_manager, migrate
def create_app(config_name: str | None = None) -> Flask:
def create_app(
config_name: str | None = None,
config_overrides: Mapping[str, Any] | None = None,
) -> Flask:
"""Create and configure an isolated Steady application instance."""
app = Flask(__name__, instance_relative_config=True)
@ -22,6 +27,9 @@ def create_app(config_name: str | None = None) -> Flask:
f"Unknown configuration {selected_config!r}; choose {valid_names}."
) from exc
if config_overrides:
app.config.from_mapping(config_overrides)
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
_validate_config(app)
_initialize_extensions(app)
@ -56,3 +64,8 @@ def _register_blueprints(app: Flask) -> None:
app.register_blueprint(auth_blueprint, url_prefix="/auth")
app.register_blueprint(tasks_blueprint, url_prefix="/tasks")
app.register_blueprint(settings_blueprint, url_prefix="/settings")
if app.config["FEATURE_ADMIN"]:
from .admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix="/admin")

View file

@ -1,2 +1,6 @@
"""Reserved package for the optional admin feature."""
from flask import Blueprint
bp = Blueprint("admin", __name__)
from . import routes # noqa: E402, F401

14
app/admin/routes.py Normal file
View file

@ -0,0 +1,14 @@
from flask import render_template
from flask_login import login_required
from app.core.authorization import roles_required
from . import bp
@bp.get("/")
@login_required
@roles_required("admin")
def index():
return render_template("admin/index.html")

View file

@ -1,14 +1,24 @@
from datetime import datetime, timezone
from flask_login import UserMixin
from sqlalchemy import String
from sqlalchemy import CheckConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
from werkzeug.security import check_password_hash, generate_password_hash
from app.extensions import db, login_manager
USER_ROLES = ("user", "admin", "viewer", "coach")
class User(UserMixin, db.Model):
__table_args__ = (
CheckConstraint(
"role IN ('user', 'admin', 'viewer', 'coach')",
name="ck_user_role",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
email: Mapped[str] = mapped_column(String(254), unique=True, index=True)
@ -36,4 +46,3 @@ def load_user(user_id: str) -> User | None:
if not user_id.isdigit():
return None
return db.session.get(User, int(user_id))

24
app/core/authorization.py Normal file
View file

@ -0,0 +1,24 @@
from collections.abc import Callable
from functools import wraps
from typing import Any
from flask import abort
from flask_login import current_user
TASK_EDITOR_ROLES = ("user", "admin", "coach")
def roles_required(*allowed_roles: str):
"""Require an authenticated user's role to match an explicit allow-list."""
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
@wraps(view)
def wrapped(*args: Any, **kwargs: Any):
if current_user.role not in allowed_roles:
abort(403)
return view(*args, **kwargs)
return wrapped
return decorator

View file

@ -12,3 +12,7 @@ def index():
def health():
return jsonify(status="ok")
@bp.app_errorhandler(403)
def forbidden(_error):
return render_template("core/403.html"), 403

View file

@ -663,6 +663,21 @@ input:focus-visible {
background: color-mix(in srgb, var(--lavender) 18%, var(--surface));
}
.admin-placeholder-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.admin-boundary {
max-width: 48rem;
margin-top: 1.5rem;
padding: var(--space);
border-left: 0.35rem solid var(--lavender);
border-radius: 0.75rem;
background: var(--surface);
}
@keyframes gentle-arrival {
from {
opacity: 0;
@ -759,6 +774,10 @@ input {
grid-template-columns: 1fr;
}
.admin-placeholder-grid {
grid-template-columns: 1fr;
}
.undo-banner {
align-items: flex-start;
flex-direction: column;

View file

@ -13,6 +13,8 @@ from flask import (
)
from flask_login import current_user, login_required
from app.core.authorization import TASK_EDITOR_ROLES, roles_required
from . import bp
from .forms import (
ActionForm,
@ -122,6 +124,7 @@ def focus_task(task_id: int):
@bp.route("/new", methods=["GET", "POST"])
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def create():
form = TaskForm()
if form.validate_on_submit():
@ -152,6 +155,7 @@ def detail(task_id: int):
@bp.route("/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def edit(task_id: int):
task = _owned_task_or_404(task_id)
form = TaskForm(obj=task)
@ -171,6 +175,7 @@ def edit(task_id: int):
@bp.post("/<int:task_id>/delete")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def delete(task_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
@ -183,6 +188,7 @@ def delete(task_id: int):
@bp.post("/<int:task_id>/subtasks")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def create_subtask(task_id: int):
task = _owned_task_or_404(task_id)
form = SubtaskForm()
@ -198,6 +204,7 @@ def create_subtask(task_id: int):
@bp.post("/<int:task_id>/subtasks/<int:subtask_id>/toggle")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def toggle_subtask_status(task_id: int, subtask_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
@ -212,6 +219,7 @@ def toggle_subtask_status(task_id: int, subtask_id: int):
@bp.post("/<int:task_id>/focus-settings")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def update_focus_settings(task_id: int):
task = _owned_task_or_404(task_id)
form = FocusSettingsForm()
@ -232,6 +240,7 @@ def update_focus_settings(task_id: int):
@bp.post("/<int:task_id>/chunks/<int:chunk_index>/toggle")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def toggle_focus_chunk(task_id: int, chunk_index: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
@ -246,6 +255,7 @@ def toggle_focus_chunk(task_id: int, chunk_index: int):
@bp.post("/<int:task_id>/complete")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def complete(task_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
@ -278,6 +288,7 @@ def transfer():
@bp.post("/import/markdown")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def import_markdown_file():
form = MarkdownImportForm()
if not form.validate_on_submit():
@ -307,6 +318,7 @@ def export_json():
@bp.post("/import/json")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def import_json():
form = BackupImportForm()
if not form.validate_on_submit():
@ -328,6 +340,7 @@ def import_json():
@bp.post("/completed/clear")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def clear_completed():
form = ActionForm()
if not form.validate_on_submit():
@ -342,6 +355,7 @@ def clear_completed():
@bp.post("/completed/undo/<uuid:batch_id>")
@login_required
@roles_required(*TASK_EDITOR_ROLES)
def undo_completed_clear(batch_id):
form = ActionForm()
if not form.validate_on_submit():

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Administration · Steady{% endblock %}
{% block content %}
<div class="page-heading">
<div>
<p class="eyebrow">Restricted placeholder</p>
<h1>Administration</h1>
<p>The admin boundary is active. Operational tools remain intentionally unavailable.</p>
</div>
</div>
<div class="admin-placeholder-grid">
<section class="transfer-card">
<h2>User management</h2>
<p>Future scope: account review, role assignment, suspension, and audited administrative actions.</p>
<span class="status">Not implemented</span>
</section>
<section class="transfer-card">
<h2>Analytics</h2>
<p>Future scope: privacy-preserving aggregate usage and operational health metrics.</p>
<span class="status">Not implemented</span>
</section>
</div>
<aside class="admin-boundary">
<h2>Current security boundary</h2>
<ul>
<li>The feature is absent unless <code>FEATURE_ADMIN</code> is enabled.</li>
<li>Only authenticated users with the <code>admin</code> role receive access.</li>
<li>No user records or analytics are queried by this placeholder.</li>
</ul>
</aside>
{% endblock %}

View file

@ -22,6 +22,9 @@
<a href="{{ url_for('tasks.index') }}">Tasks</a>
<a href="{{ url_for('tasks.transfer') }}">Data</a>
<a href="{{ url_for('settings.index') }}">Settings</a>
{% if config.FEATURE_ADMIN and current_user.role == 'admin' %}
<a href="{{ url_for('admin.index') }}">Admin</a>
{% endif %}
<span>Hi, {{ current_user.username }}</span>
<form action="{{ url_for('auth.logout') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Access unavailable · Steady{% endblock %}
{% block content %}
<section class="empty-state">
<p class="eyebrow">Access unavailable</p>
<h1>This area is not available for your role</h1>
<p>Your account and tasks are unchanged.</p>
<a class="button" href="{{ url_for('core.index') }}">Return home</a>
</section>
{% endblock %}

View file

@ -5,6 +5,13 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
def environment_flag(name: str, *, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
class Config:
"""Shared settings with secure production-oriented defaults."""
@ -23,7 +30,7 @@ class Config:
WTF_CSRF_TIME_LIMIT = 3600
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
FEATURE_ADMIN = False
FEATURE_ADMIN = environment_flag("FEATURE_ADMIN")
FEATURE_API_V1 = False
FEATURE_BROWSER_REMINDERS = False

View file

@ -252,10 +252,10 @@ a,
- [x] Browser Reminders (Web Push API Placeholder) [2]
### Phase 6: Admin Dashboard (Placeholder - Future) [3]
- [ ] Admin Route Placeholder [3]
- [x] Admin Route Placeholder [3]
- [ ] User Management UI (Future) [3]
- [ ] Analytics Dashboard (Future) [3]
- [ ] Role-based Access Control (Admin, User, Viewer, Coach) [3]
- [x] Role-based Access Control (Admin, User, Viewer, Coach) [3]
### Phase 7: Polish & Testing (Week 9-10)
- [ ] Responsive Design Testing [2]

View file

@ -0,0 +1,35 @@
"""Constrain user roles
Revision ID: c79217aea3e8
Revises: 5ad737bbca5e
Create Date: 2026-08-08 04:08:30.449389
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c79217aea3e8'
down_revision = '5ad737bbca5e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_check_constraint(
'ck_user_role',
"role IN ('user', 'admin', 'viewer', 'coach')",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_constraint('ck_user_role', type_='check')
# ### end Alembic commands ###

View file

@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
## Current Status
- Current phase: Phase 5 — Settings & Personalization
- Status: Complete
- Current phase: Phase 6 — Admin Dashboard Placeholder
- Status: Complete — placeholder scope
- Last updated: 2026-08-08
- Next phase: Phase 6 — Admin Dashboard Placeholder
- Next phase: Phase 7 — Polish & Testing
## Phase 1 — Core Infrastructure
@ -295,6 +295,59 @@ No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully.
```
## Phase 6 — Admin Dashboard Placeholder
### Phase 6.1 — Role and feature boundary
Added a database role constraint for `user`, `admin`, `viewer`, and `coach`; a reusable allow-list authorization decorator; a disabled-by-default `FEATURE_ADMIN` environment flag; and safe application-factory overrides for isolated feature testing. The admin Blueprint is imported and registered only when enabled.
Added an admin-only placeholder dashboard and a calm `403` page. The dashboard explicitly labels user management and analytics as unimplemented and queries no user or analytics data. Admin navigation appears only when both the feature flag and role allow it.
Why: a disabled route is safer than a visible placeholder protected only by convention. Role checks use an explicit allow-list, the database rejects unknown roles, and conditional imports keep the future feature absent from the route map when disabled. Factory overrides allow testing without changing deployment defaults.
Next: enforce the viewer role as read-only across every task mutation, migrate the role constraint, and test all role boundaries.
### Phase 6.2 — Viewer read-only enforcement
Applied the shared task-editor allow-list (`user`, `admin`, `coach`) to every task-changing route: create, edit, delete, completion, subtasks, focus configuration, chunk toggles, both imports, clear, and undo. Viewer accounts retain authenticated access to lists, details, Focus Mode, export, and personal settings but receive `403` before mutation logic runs.
Why: defining a viewer role without enforcing read-only behavior would create a misleading security promise. Centralizing the allow-list prevents individual routes from inventing different role semantics, while ownership checks continue to restrict editors to their own tasks.
Next: generate the role-constraint migration and test disabled, anonymous, non-admin, admin, viewer, and coach behavior.
The `Constrain user roles` migration was generated, reviewed, applied, and followed by a green 45-test regression run.
### Phase 6.3 — Authorization coverage
Added tests for default route absence, anonymous login redirection, `403` responses for user/viewer/coach roles, admin placeholder access, database rejection of unknown roles, viewer read access, all twelve viewer mutation denials, and retained coach write access to owned tasks.
Why: checking only the admin landing page would leave lateral privilege gaps in task operations. Enumerating every mutation proves the viewer contract across uploads, recovery, focus actions, subtasks, and CRUD. The database test verifies defense in depth beneath route authorization.
Next: run the focused role suite, then the complete regression and architecture checks.
### Phase 6.4 — Final verification and scope boundary
Completed the focused authorization suite and full regression, Alembic model comparison, default and enabled route discovery, Python compilation, JavaScript syntax validation, dependency consistency, line-length and whitespace scans, and patch-format validation.
User-management and analytics operations remain deliberately unimplemented. The design labels both as future work, and adding role assignment, account suspension, or analytics collection would require explicit product requirements, audit logging, privacy rules, and recovery policy. Phase 6 therefore completes the safe placeholder and authorization boundary without creating speculative administrative power.
Final results:
```text
54 tests passed in 5.49s
All 9 focused authorization tests passed.
The default route map contains no admin endpoint.
The enabled route map contains the guarded /admin/ placeholder.
Alembic detected no pending model operations.
Python and JavaScript syntax validation completed successfully.
No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully.
```
## Next Work
Phase 6 is intentionally a future-facing admin placeholder. The next decision should be whether to implement only a safely disabled admin Blueprint and role guard as designed, or defer all admin surface area until concrete user-management requirements exist.
Phase 7 will focus on evidence-driven polish: responsive behavior, reduced-motion verification, accessibility auditing, performance checks, and deployment/documentation hardening. Future admin operations should remain separate until their requirements are approved.
## Local Operational Setup
- 2026-08-08: Promoted the sole local development account from `user` to `admin` and enabled `FEATURE_ADMIN=true` in the ignored local `.env` file. Verified through the Flask CLI route map that `/admin/` is registered. No account identifier or credential is recorded in this log.

149
tests/admin/test_admin.py Normal file
View file

@ -0,0 +1,149 @@
from uuid import uuid4
import pytest
from sqlalchemy.exc import IntegrityError
from app import create_app
from app.auth.models import User
from app.auth.services import create_user
from app.extensions import db as database
from app.tasks.services import add_subtask, configure_focus, create_task
@pytest.fixture()
def admin_app():
application = create_app("testing", {"FEATURE_ADMIN": True})
with application.app_context():
database.create_all()
yield application
database.session.remove()
database.drop_all()
@pytest.fixture()
def admin_client(admin_app):
return admin_app.test_client()
def login(client, email: str) -> None:
client.post(
"/auth/login",
data={"email": email, "password": "secure password"},
)
def user_with_role(username: str, role: str) -> User:
user = create_user(
username,
f"{username}@example.com",
"secure password",
)
user.role = role
database.session.commit()
return user
def test_admin_feature_is_absent_by_default(app, client):
endpoints = {rule.endpoint for rule in app.url_map.iter_rules()}
assert "admin.index" not in endpoints
assert client.get("/admin/").status_code == 404
def test_enabled_admin_redirects_anonymous_users(admin_client):
response = admin_client.get("/admin/")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
@pytest.mark.parametrize("role", ["user", "viewer", "coach"])
def test_non_admin_roles_receive_calm_forbidden_page(admin_client, role):
user = user_with_role(f"non_admin_{role}", role)
login(admin_client, user.email)
response = admin_client.get("/admin/")
assert response.status_code == 403
assert b"not available for your role" in response.data
def test_admin_can_open_data_free_placeholder(admin_client):
user = user_with_role("admin_user", "admin")
login(admin_client, user.email)
response = admin_client.get("/admin/")
assert response.status_code == 200
assert b"User management" in response.data
assert b"Analytics" in response.data
assert response.data.count(b"Not implemented") == 2
assert b'href="/admin/"' in response.data
def test_database_rejects_unknown_roles(admin_app):
user = User(
username="invalid_role",
email="invalid-role@example.com",
role="superuser",
)
user.set_password("secure password")
database.session.add(user)
with pytest.raises(IntegrityError):
database.session.commit()
database.session.rollback()
def test_viewer_can_read_but_every_task_mutation_is_forbidden(client, db):
viewer = user_with_role("readonly_user", "viewer")
task = create_task(
viewer.id,
title="Readable task",
description=None,
status="not_started",
priority="normal",
due_date=None,
)
subtask = add_subtask(task, "Readable step")
configure_focus(task, timer_minutes=5, context=None, chunk_minutes=[5])
login(client, viewer.email)
assert client.get("/tasks/").status_code == 200
assert client.get(f"/tasks/{task.id}").status_code == 200
assert client.get("/tasks/export/json").status_code == 200
operations = [
("get", "/tasks/new"),
("get", f"/tasks/{task.id}/edit"),
("post", f"/tasks/{task.id}/delete"),
("post", f"/tasks/{task.id}/subtasks"),
("post", f"/tasks/{task.id}/subtasks/{subtask.id}/toggle"),
("post", f"/tasks/{task.id}/focus-settings"),
("post", f"/tasks/{task.id}/chunks/0/toggle"),
("post", f"/tasks/{task.id}/complete"),
("post", "/tasks/import/markdown"),
("post", "/tasks/import/json"),
("post", "/tasks/completed/clear"),
("post", f"/tasks/completed/undo/{uuid4()}"),
]
for method, path in operations:
response = getattr(client, method)(path)
assert response.status_code == 403, path
def test_coach_retains_owned_task_write_access(client, db):
coach = user_with_role("coach_user", "coach")
login(client, coach.email)
response = client.post(
"/tasks/new",
data={
"title": "Coach-owned task",
"status": "not_started",
"priority": "normal",
"due_date": "",
},
)
assert response.status_code == 302