Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(gh auth:*)",
"Bash(git remote:*)",
"Bash(gh issue:*)",
"mcp__zenable__conformance_check",
"Bash(git -C c:/Users/student/code/todo-app checkout -b feature/issue-13-color-theme)",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains an absolute local filesystem path (c:/Users/student/code/todo-app) which is developer-machine-specific and should not be committed to the repository. This leaks the local environment structure and will break for any other contributor. The .claude/settings.local.json file should be added to .gitignore rather than committed.

Why did I show this?

Category: security
Comment Quality: high

Influenced by requirements:

Tools used:

  1. list_changed_files, {'pattern': {'type': 'string', 'value': '**/*.css'}}
  2. list_changed_files, {'pattern': '**/*.css'}
  3. get_file_lines, {'file_path': 'src/static/css/tokens.css', 'start_line': 1, 'end_line': 200}
  4. get_file_lines, {'file_path': 'src/static/css/base.css', 'start_line': 1, 'end_line': 200}
  5. get_file_lines, {'file_path': 'src/static/css/components.css', 'start_line': 1, 'end_line': 200}
  6. get_file_lines, {'file_path': 'src/templates/index.html', 'start_line': 1, 'end_line': 200}
  7. get_file_lines, {'file_path': 'src/templates/login.html', 'start_line': 1, 'end_line': 200}

"Bash(git add:*)",
"Bash(git commit -m ':*)",
"Bash(git config:*)",
"Bash(git commit -m 'feat\\(ui\\): implement color theme and security hardening \\(#13\\):*)",
"Bash(docker info:*)",
"Bash(.venv/Scripts/python.exe -mpre_commit run --all-files)"
]
}
}
4 changes: 4 additions & 0 deletions .github/workflows/commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@v6
Expand All @@ -32,6 +34,8 @@ jobs:
test:
name: Test
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
Expand Down
125 changes: 125 additions & 0 deletions docs/specs/issue-13-color-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Spec: Color Theme (Issue #13)

## Overview

Implement a professional color palette and consistent typography across the todo-app UI to establish a cohesive visual identity and improve usability.

## Background

**Issue**: [#13 Color Theme](https://github.com/labworksdev/todo-app/issues/13)
**Labels**: `tier-1`, `ui-ux`, `sprint-1`, `demo9`
**Assignee**: demo9-labworksdev

The application currently lacks a defined visual design system. This spec establishes the foundational design tokens — colors, typography, and component styles — that will be applied consistently across the UI.

---

## Goals

- Define a cohesive color palette using CSS custom properties (design tokens)
- Establish consistent heading and body font styles
- Style all interactive elements: buttons, inputs, and links
- Create clear visual hierarchy to guide user attention

## Non-Goals

- Dark mode support (can be a follow-up)
- Animations or transitions (separate concern)
- Responsive/mobile-specific layout changes

---

## Design Tokens

### Color Palette

| Token | Value | Usage |
|-------|-------|-------|
| `--color-primary` | `#2563EB` | Primary actions, links |
| `--color-primary-hover` | `#1D4ED8` | Hover state for primary |
| `--color-secondary` | `#64748B` | Secondary text, borders |
| `--color-success` | `#16A34A` | Completed todos, confirmations |
| `--color-danger` | `#DC2626` | Delete actions, errors |
| `--color-warning` | `#D97706` | Warnings, pending states |
| `--color-bg` | `#F8FAFC` | Page background |
| `--color-surface` | `#FFFFFF` | Card/panel backgrounds |
| `--color-border` | `#E2E8F0` | Borders, dividers |
| `--color-text` | `#1E293B` | Primary body text |
| `--color-text-muted` | `#94A3B8` | Placeholder, disabled text |

### Typography

| Token | Value | Usage |
|-------|-------|-------|
| `--font-family` | `Inter, system-ui, sans-serif` | All text |
| `--font-size-sm` | `0.875rem` (14px) | Labels, captions |
| `--font-size-base` | `1rem` (16px) | Body text |
| `--font-size-lg` | `1.125rem` (18px) | Subheadings |
| `--font-size-xl` | `1.25rem` (20px) | Section headings |
| `--font-size-2xl` | `1.5rem` (24px) | Page title |
| `--font-weight-normal` | `400` | Body text |
| `--font-weight-medium` | `500` | Labels, nav items |
| `--font-weight-bold` | `700` | Headings |
| `--line-height-body` | `1.6` | Body text |
| `--line-height-heading` | `1.2` | Headings |

---

## Component Styles

### Buttons

| Variant | Background | Text | Border | Use Case |
|---------|------------|------|--------|----------|
| Primary | `--color-primary` | white | none | Add todo, Save |
| Secondary | transparent | `--color-secondary` | `--color-border` | Cancel, Back |
| Danger | `--color-danger` | white | none | Delete todo |

All buttons: `border-radius: 6px`, `padding: 0.5rem 1rem`, `font-weight: medium`.

### Inputs

- Border: `1px solid --color-border`
- Border radius: `6px`
- Padding: `0.5rem 0.75rem`
- Focus ring: `2px solid --color-primary` with `outline-offset: 2px`
- Placeholder text color: `--color-text-muted`

### Links

- Default: `--color-primary`, no underline
- Hover: `--color-primary-hover`, underline
- Visited: `--color-secondary`

### Visual Hierarchy

1. **Page title** — `2xl`, `bold`, `--color-text`
2. **Section headings** — `xl`, `bold`, `--color-text`
3. **Item titles** — `base`, `medium`, `--color-text`
4. **Supporting text** — `sm`, `normal`, `--color-text-muted`

---

## Implementation Plan

1. **Create `static/css/tokens.css`** — define all CSS custom properties under `:root`
2. **Create `static/css/base.css`** — apply tokens to HTML elements (`body`, `h1`–`h4`, `a`, `input`, `button`)
3. **Create `static/css/components.css`** — button variants, input states, links
4. **Import stylesheets** in the base HTML template
5. **Audit existing templates** — replace any hardcoded colors/fonts with token references

## Acceptance Criteria

- [ ] All color and typography values sourced from CSS custom properties
- [ ] Page title, section headings, body text, and muted text are visually distinct
- [ ] Primary, secondary, and danger button variants are implemented and used consistently
- [ ] Input fields have visible focus states meeting <!-- cspell: ignore WCAG -->WCAG 2.1 AA contrast requirements
- [ ] Links are distinguishable from body text without relying solely on color
- [ ] No hardcoded hex values or font names outside of `tokens.css`

---

## References

- [ARCHITECTURE.md](../ARCHITECTURE.md) — project structure
- [<!-- cspell: ignore WCAG -->WCAG 2.1 AA contrast guidelines](https://www.w3.org/TR/WCAG21/#contrast-minimum)
178 changes: 178 additions & 0 deletions scripts/generate_password_hash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Generate a PBKDF2-SHA256 password hash for use as ADMIN_PASSWORD_HASH.

Usage:
SETUP_SECRET=<value> python scripts/generate_password_hash.py

The SETUP_SECRET environment variable must match the value set at deploy time
to prevent unauthorized execution of this script.

Required environment variables:
SETUP_SECRET Primary authorization secret.
SETUP_TOKEN Secondary authorization token — acts as a second factor so
knowledge of SETUP_SECRET alone is insufficient to run the script.

Optional environment variables:
PBKDF2_ITERATIONS Number of PBKDF2 iterations (default: 600000).
The value is embedded in the hash output so
verification is self-contained — increase it over
time without breaking existing hashes.
PBKDF2_MIN_ITERATIONS Minimum acceptable iteration count (default: 200000).
Raise this over time as hardware improves.
"""

import getpass
import hashlib
import hmac
import json
import os
import secrets
import sys
import time
from pathlib import Path

_DEFAULT_ITERATIONS = 600_000
_FALLBACK_MIN_ITERATIONS = 200_000
_MIN_PASSWORD_LENGTH = 12
_MIN_SECRET_LENGTH = 32 # Minimum length for SETUP_SECRET and SETUP_TOKEN
_MAX_ATTEMPTS = 5
_LOCKOUT_SECONDS = 900 # 15 minutes
_ATTEMPT_DELAY_SECONDS = 2 # Slow brute-force after each failure
_ATTEMPT_FILE = Path.home() / ".todo-app-setup-attempts"


def _check_rate_limit() -> None:
"""Enforce a lockout after repeated failed credential attempts. Exits on violation."""
try:
if _ATTEMPT_FILE.exists():
data = json.loads(_ATTEMPT_FILE.read_text())
failures = data.get("failures", 0)
locked_until = data.get("locked_until", 0)
if failures >= _MAX_ATTEMPTS and time.time() < locked_until:
remaining = int(locked_until - time.time())
print(f"Error: too many failed attempts. Try again in {remaining}s.", file=sys.stderr)
sys.exit(1)
except (OSError, json.JSONDecodeError, KeyError):
pass # Corrupt or missing file — allow the attempt.


def _record_failure() -> None:
"""Increment the on-disk failure counter, set a lockout timestamp, and delay to slow brute-force."""
try:
data: dict = {}
if _ATTEMPT_FILE.exists():
data = json.loads(_ATTEMPT_FILE.read_text())
failures = data.get("failures", 0) + 1
locked_until = time.time() + _LOCKOUT_SECONDS if failures >= _MAX_ATTEMPTS else 0
_ATTEMPT_FILE.write_text(json.dumps({"failures": failures, "locked_until": locked_until}))
except OSError:
pass
# Always delay after a failure to slow down automated attacks.
time.sleep(_ATTEMPT_DELAY_SECONDS)


def _clear_failures() -> None:
"""Remove the failure record after a successful authentication."""
try:
_ATTEMPT_FILE.unlink(missing_ok=True)
except OSError:
pass


def _check_password_strength(password: str) -> list[str]:
"""Return a list of unmet password requirements; empty list means the password passes."""
errors = []
if len(password) < _MIN_PASSWORD_LENGTH:
errors.append(f"at least {_MIN_PASSWORD_LENGTH} characters")
if not any(c.isupper() for c in password):
errors.append("at least one uppercase letter")
if not any(c.islower() for c in password):
errors.append("at least one lowercase letter")
if not any(c.isdigit() for c in password):
errors.append("at least one digit")
if not any(not c.isalnum() for c in password):
errors.append("at least one special character (e.g. !@#$%)")
return errors


def main() -> None:
"""Verify the setup secret, then prompt for a password and print its hash."""
_check_rate_limit()

expected = os.environ.get("SETUP_SECRET")
if not expected:
print(
"Error: SETUP_SECRET environment variable is not set.",
file=sys.stderr,
)
sys.exit(1)
if len(expected) < _MIN_SECRET_LENGTH:
print(
f"Error: SETUP_SECRET must be at least {_MIN_SECRET_LENGTH} characters.",

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (secret)
as clear text.
file=sys.stderr,
)
sys.exit(1)

expected_token = os.environ.get("SETUP_TOKEN")
if not expected_token:
print("Error: SETUP_TOKEN environment variable is not set.", file=sys.stderr)
sys.exit(1)
if len(expected_token) < _MIN_SECRET_LENGTH:
print(
f"Error: SETUP_TOKEN must be at least {_MIN_SECRET_LENGTH} characters.",

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (secret)
as clear text.
file=sys.stderr,
)
sys.exit(1)

provided = getpass.getpass("Setup secret: ")
provided_token = getpass.getpass("Setup token: ")
# Both comparisons always run (results stored before the conditional) so
# neither leaks which credential was wrong via a short-circuit timing difference.
secret_ok = hmac.compare_digest(provided.encode(), expected.encode())
token_ok = hmac.compare_digest(provided_token.encode(), expected_token.encode())
if not (secret_ok and token_ok):
_record_failure()
print("Error: incorrect setup credentials.", file=sys.stderr)
sys.exit(1)

_clear_failures()

try:
min_iterations = int(os.environ.get("PBKDF2_MIN_ITERATIONS", str(_FALLBACK_MIN_ITERATIONS)))
if min_iterations < 1:
raise ValueError
except ValueError:
print("Error: PBKDF2_MIN_ITERATIONS must be a positive integer.", file=sys.stderr)
sys.exit(1)

iterations_env = os.environ.get("PBKDF2_ITERATIONS", str(_DEFAULT_ITERATIONS))
try:
iterations = int(iterations_env)
if iterations < min_iterations:
raise ValueError
except ValueError:
print(
f"Error: PBKDF2_ITERATIONS must be an integer >= {min_iterations}.",
file=sys.stderr,
)
sys.exit(1)

password = getpass.getpass("New password: ")
strength_errors = _check_password_strength(password)
if strength_errors:
print("Error: password does not meet requirements:", file=sys.stderr)
for err in strength_errors:
print(f" - {err}", file=sys.stderr)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
sys.exit(1)

confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("Error: passwords do not match.", file=sys.stderr)
sys.exit(1)
Comment on lines +168 to +170
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password confirmation comparison uses != (non-constant-time) rather than hmac.compare_digest. While the attacker controls both inputs here (it's their own password), this is inconsistent with the security posture of the rest of the script and could be flagged in a security audit. Use a constant-time comparison:

Suggested change
if password != confirm:
print("Error: passwords do not match.", file=sys.stderr)
sys.exit(1)
if not hmac.compare_digest(password.encode(), confirm.encode()):
Why did I show this?

Category: security
Comment Quality: high

Based on general best practices

Tools used:

  1. list_changed_files, {'pattern': {'type': 'string', 'value': '**/*.py'}}
  2. list_changed_files, {'pattern': '**/*.py'}
  3. list_changed_files, {'pattern': '**/*.py'}
  4. get_file_lines, {'file_path': 'src/todo_app/app.py', 'start_line': 1, 'end_line': 200}


salt = os.urandom(16)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
print(f"{iterations}:{salt.hex()}:{key.hex()}")


if __name__ == "__main__":
main()
Loading
Loading