8 Commits

Author SHA1 Message Date
6092a4454e Chore: Update .gitignore 2026-05-03 16:29:57 -04:00
28df9a1261 Feat: Header title links to grid; sign-out redirects to grid
Make the app title a clickable link to / so users can return to the
image grid from any sub-page without the browser back button. Change
the sign-out destination from /login to / since the grid is publicly
accessible and avoids unnecessary friction post-logout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:14:35 +00:00
9246f75fdd Feat: Polish Angular UI with cohesive design system
Introduces a shared CSS custom property token layer and applies it
across all five views (library, upload, detail, login, app shell).
Each view now has intentional loading, empty, and error states.

- styles.css: 13 design tokens on :root; shimmer skeleton animation
- Library: 150ms-debounced skeleton loading, empty state with /upload
  link, error card with retry, card hover lift, broken-image fallback
- Upload: token-styled drop-zone, Uploading… spinner, 4s success
  banner, distinct validation vs. network error messages
- Detail: image skeleton, network error card (separate from 404
  not-found card), Owner actions panel, danger tag error styling,
  broken-image fallback
- Login: vertically centred surface card, danger field/server errors,
  Signing in… disabled button
- App shell: 48px fixed header, app name left, sign-out right, no
  reflow on auth state change
- All 24 ESLint errors resolved (including pre-existing auth spec
  issues); ng build and ng lint pass clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:03:56 +00:00
5179786261 Docs: Bump constitution to v1.2.0 — reflect JWT auth completion
Phase 2 (JWT bearer) is shipped; update §2.4 phase status, add PyJWT
to §6 tech stack table, remove username/password from §8 out-of-scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:17:18 +00:00
86961d19ee Chore: Add updated files 2026-05-03 15:13:35 -04:00
5fbbc1e67f Feat: Implement JWT bearer token authentication
Protects image upload, delete, and tag-update endpoints behind
Bearer token auth. Public read endpoints remain open. Angular SPA
gains a login page, auth interceptor, and route guard for /upload.

- JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest)
- POST /api/v1/auth/token login endpoint
- require_auth FastAPI dependency on all write routes
- AuthService, LoginComponent, authInterceptor, authGuard
- Detail page hides write controls for unauthenticated visitors
- 43 unit tests passing; integration tests require Docker stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:12:38 +00:00
d91a65abe5 Docs: Update scope boundaries in constitution
Due to the introduction of image thumbnail generation in
cd89ba5dea, the scope boundaries in §10 of
the project constitution should be updated with a clarification.
2026-05-03 14:02:51 -04:00
ec7bf591a4 Chore: Add example image to README.md 2026-05-03 13:46:16 -04:00
62 changed files with 22663 additions and 218 deletions

View File

@@ -13,3 +13,9 @@ API_BASE_URL=http://localhost:8000
# Upload size limit in bytes (default 50 MiB) # Upload size limit in bytes (default 50 MiB)
MAX_UPLOAD_BYTES=52428800 MAX_UPLOAD_BYTES=52428800
# Owner credentials and JWT signing secret
JWT_SECRET_KEY=change-me-to-a-long-random-string
JWT_EXPIRY_SECONDS=86400
OWNER_USERNAME=owner
OWNER_PASSWORD=change-me

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Developer notes
notes/
# Environment # Environment
.env .env
.env.* .env.*

BIN
.img/reactbin-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

View File

@@ -1,3 +1 @@
{ {"feature_directory":"specs/006-header-nav-signout"}
"feature_directory": "specs/003-upload-thumbnails"
}

View File

@@ -1,8 +1,8 @@
<!-- <!--
SYNC IMPACT REPORT SYNC IMPACT REPORT
================== ==================
Version change: [TEMPLATE — no prior version] → 1.1.0 Version change: 1.1.1 → 1.2.0
Ratified: 2026-05-01 | Last amended: 2026-05-02 Ratified: 2026-05-01 | Last amended: 2026-05-03
Principles introduced (first population from docs/CONSTITUTION.md): Principles introduced (first population from docs/CONSTITUTION.md):
- §2 Architecture Principles (6 sub-principles) - §2 Architecture Principles (6 sub-principles)
@@ -82,14 +82,14 @@ or SDK-specific types directly — only the interface contract.
### 2.4 Auth abstraction (progressive) ### 2.4 Auth abstraction (progressive)
Authentication is treated as a pluggable backend from day one, even though Authentication is treated as a pluggable backend from day one. The API MUST
Phase 1 ships with no auth. The API MUST route all request-identity resolution route all request-identity resolution through a single `AuthProvider` interface.
through a single `AuthProvider` interface. The no-op provider (Phase 1) returns Each phase introduces a new provider implementation; no phase rewrites business
a static anonymous identity. Adding username/password or OIDC in a later phase logic already behind the interface.
MUST be a new provider implementation, not a rewrite of business logic.
**Phase 1 implements: no-auth (localhost only).** **Phase 1 — no-auth (NoOpAuthProvider): complete.**
**Planned phases: username/password, then OIDC.** **Phase 2 — JWT bearer token (JWTAuthProvider, HS256, single owner): complete.**
**Phase 3 — OIDC: planned.**
The constitution acknowledges all three; the spec governs which is built. The constitution acknowledges all three; the spec governs which is built.
### 2.5 Database abstraction ### 2.5 Database abstraction
@@ -199,16 +199,17 @@ NOT be marked complete while CI is failing.
## 6. Tech Stack Constraints ## 6. Tech Stack Constraints
| Concern | Choice | Rationale | | Concern | Choice | Rationale |
|---|---|---| |------------------|-------------------------------------------|-------------------------------------------|
| API language | Python 3.12+ | Primary language, type hints required | | API language | Python 3.12+ | Primary language, type hints required |
| API framework | FastAPI | Async, OpenAPI-native | | API framework | FastAPI | Async, OpenAPI-native |
| ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries | | ORM / query | SQLAlchemy 2.x (async) + asyncpg driver | Repository layer owns all queries |
| DB migrations | Alembic | Schema changes tracked in version control | | DB migrations | Alembic | Schema changes tracked in version control |
| Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config | | Object storage | S3-compatible via `boto3` / `aiobotocore` | Swap MinIO ↔ S3 via env config |
| UI framework | Angular (latest stable) | Job-relevant, learning goal | | Auth tokens | PyJWT (HS256) | Lightweight; compatible with OIDC migration path |
| UI language | TypeScript strict mode | No `any`, no implicit types | | UI framework | Angular (latest stable) | Job-relevant, learning goal |
| Containerisation | Docker + Docker Compose | Local dev must start with one command | | UI language | TypeScript strict mode | No `any`, no implicit types |
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
--- ---
@@ -241,10 +242,9 @@ revised:
- Multi-user support - Multi-user support
- Public sharing or embeds - Public sharing or embeds
- Collections or albums beyond tag-based grouping - Collections or albums beyond tag-based grouping
- Image editing or transformation - Image editing or transformation beyond thumbnail generation
- OR/NOT tag logic - OR/NOT tag logic
- Mobile-native app - Mobile-native app
- Username/password auth (planned Phase 2)
- OIDC auth (planned Phase 3) - OIDC auth (planned Phase 3)
--- ---
@@ -277,12 +277,14 @@ Phase 1 design is complete.
## 10. Revision Log ## 10. Revision Log
| Version | Date | Change | | Version | Date | Change |
|---|---|---| |---------|------------|---------------------------------------------------------------------------------------------------------------------------------|
| 1.0.0 | 2026-05-01 | Initial constitution | | 1.0.0 | 2026-05-01 | Initial constitution |
| 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope | | 1.1.0 | 2026-05-01 | asyncpg driver explicit; SHA-256 deduplication added to data model; deduplication removed from out-of-scope |
| 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance | | 1.1.0 | 2026-05-02 | Adopted into Spec Kit memory; fixed duplicate §4.3 → §4.4; strengthened "should" language to MUST/MUST NOT; added §9 Governance |
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
--- ---
**Version**: 1.1.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-02 **Version**: 1.2.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-03

View File

@@ -1,5 +1,5 @@
<!-- SPECKIT START --> <!-- SPECKIT START -->
For additional context about technologies to be used, project structure, For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at shell commands, and other important information, read the current plan at
`specs/003-upload-thumbnails/plan.md`. `specs/005-ui-polish/plan.md`.
<!-- SPECKIT END --> <!-- SPECKIT END -->

View File

@@ -1,2 +1,4 @@
# reactbin # reactbin
Organize your reaction images. _Organize your reaction images._
![Reactbin UI](.img/reactbin-ui.png)

View File

@@ -0,0 +1,51 @@
import secrets
from datetime import UTC, datetime, timedelta
import jwt
from fastapi import HTTPException
from app.auth.provider import AuthProvider, Identity
_UNAUTHORIZED = HTTPException(
status_code=401, detail={"detail": "Unauthorized", "code": "unauthorized"}
)
class JWTAuthProvider(AuthProvider):
def __init__(
self,
secret_key: str,
expiry_seconds: int,
owner_username: str,
owner_password: str,
) -> None:
self._secret_key = secret_key
self._expiry_seconds = expiry_seconds
self._owner_username = owner_username
self._owner_password = owner_password
def create_token(self) -> str:
now = datetime.now(tz=UTC)
payload = {
"sub": "owner",
"iat": now,
"exp": now + timedelta(seconds=self._expiry_seconds),
}
return jwt.encode(payload, self._secret_key, algorithm="HS256")
def verify_credentials(self, username: str, password: str) -> bool:
username_ok = secrets.compare_digest(username, self._owner_username)
password_ok = secrets.compare_digest(password, self._owner_password)
return username_ok and password_ok
async def get_identity(self, authorization: str | None) -> Identity:
if not authorization or not authorization.startswith("Bearer "):
raise _UNAUTHORIZED
token = authorization.removeprefix("Bearer ")
try:
jwt.decode(token, self._secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise _UNAUTHORIZED from None
except jwt.InvalidTokenError:
raise _UNAUTHORIZED from None
return Identity(id="owner", anonymous=False)

View File

@@ -4,5 +4,5 @@ _ANONYMOUS = Identity(id="anonymous", anonymous=True)
class NoOpAuthProvider(AuthProvider): class NoOpAuthProvider(AuthProvider):
async def get_identity(self) -> Identity: async def get_identity(self, authorization: str | None) -> Identity:
return _ANONYMOUS return _ANONYMOUS

View File

@@ -10,5 +10,5 @@ class Identity:
class AuthProvider(ABC): class AuthProvider(ABC):
@abstractmethod @abstractmethod
async def get_identity(self) -> Identity: async def get_identity(self, authorization: str | None) -> Identity:
"""Resolve the request identity.""" """Resolve the request identity from the Authorization header value."""

View File

@@ -1,4 +1,5 @@
from functools import lru_cache from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -13,6 +14,10 @@ class Settings(BaseSettings):
s3_region: str = "us-east-1" s3_region: str = "us-east-1"
api_base_url: str = "http://localhost:8000" api_base_url: str = "http://localhost:8000"
max_upload_bytes: int = 52_428_800 # 50 MiB max_upload_bytes: int = 52_428_800 # 50 MiB
jwt_secret_key: str
jwt_expiry_seconds: int = 86400
owner_username: str
owner_password: str
@lru_cache @lru_cache

View File

@@ -1,10 +1,10 @@
from typing import AsyncGenerator from collections.abc import AsyncGenerator
from fastapi import Depends from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.noop import NoOpAuthProvider from app.auth.jwt_provider import JWTAuthProvider
from app.auth.provider import AuthProvider from app.auth.provider import AuthProvider, Identity
from app.database import get_session_factory from app.database import get_session_factory
from app.storage.backend import StorageBackend from app.storage.backend import StorageBackend
from app.storage.s3_backend import S3StorageBackend from app.storage.s3_backend import S3StorageBackend
@@ -23,12 +23,38 @@ def get_storage() -> StorageBackend:
def get_auth() -> AuthProvider: def get_auth() -> AuthProvider:
global _auth global _auth
if _auth is None: if _auth is None:
_auth = NoOpAuthProvider() from app.config import get_settings
s = get_settings()
_auth = JWTAuthProvider(
secret_key=s.jwt_secret_key,
expiry_seconds=s.jwt_expiry_seconds,
owner_username=s.owner_username,
owner_password=s.owner_password,
)
return _auth return _auth
def get_jwt_auth() -> JWTAuthProvider:
auth = get_auth()
assert isinstance(auth, JWTAuthProvider)
return auth
async def require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth),
) -> Identity:
identity = await auth.get_identity(authorization)
if identity.anonymous:
raise HTTPException(
status_code=401,
detail={"detail": "Authentication required", "code": "unauthorized"},
)
return identity
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory() factory = get_session_factory()
async with factory() as session: async with factory() as session, session.begin():
async with session.begin(): yield session
yield session

View File

@@ -5,12 +5,12 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.config import get_settings from app.config import get_settings
from app.database import get_engine, get_session_factory, Base from app.database import Base, get_engine
@asynccontextmanager @asynccontextmanager
async def lifespan(application: FastAPI): async def lifespan(application: FastAPI):
settings = get_settings() get_settings()
# Verify DB connection and run migrations on startup # Verify DB connection and run migrations on startup
engine = get_engine() engine = get_engine()
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -36,7 +36,8 @@ async def health():
# Routers registered after all modules are defined to avoid circular imports # Routers registered after all modules are defined to avoid circular imports
from app.routers import images, tags # noqa: E402 from app.routers import auth, images, tags # noqa: E402
app.include_router(auth.router, prefix="/api/v1")
app.include_router(images.router, prefix="/api/v1") app.include_router(images.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1") app.include_router(tags.router, prefix="/api/v1")

33
api/app/routers/auth.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.auth.jwt_provider import JWTAuthProvider
from app.dependencies import get_jwt_auth
router = APIRouter(tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
@router.post("/auth/token", response_model=TokenResponse)
async def login(body: LoginRequest, auth: JWTAuthProvider = Depends(get_jwt_auth)):
if not auth.verify_credentials(body.username, body.password):
raise HTTPException(
status_code=401,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
token = auth.create_token()
return TokenResponse(
access_token=token,
token_type="bearer",
expires_in=auth._expiry_seconds,
)

View File

@@ -7,9 +7,9 @@ from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.provider import AuthProvider from app.auth.provider import Identity
from app.config import get_settings from app.config import get_settings
from app.dependencies import get_auth, get_db, get_storage from app.dependencies import get_db, get_storage, require_auth
from app.models import Image from app.models import Image
from app.repositories.image_repo import ImageRepository from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository from app.repositories.tag_repo import TagRepository
@@ -109,7 +109,7 @@ async def upload_image(
tags: str | None = Form(None), tags: str | None = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage), storage: StorageBackend = Depends(get_storage),
auth: AuthProvider = Depends(get_auth), _: Identity = Depends(require_auth),
settings=Depends(get_settings), settings=Depends(get_settings),
): ):
data = await file.read() data = await file.read()
@@ -163,7 +163,9 @@ async def upload_image(
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp") await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{hash_hex}-thumb" thumbnail_key = f"{hash_hex}-thumb"
except Exception: except Exception:
logger.warning("Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex) logger.warning(
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
)
image = await image_repo.create( image = await image_repo.create(
hash_hex=hash_hex, hash_hex=hash_hex,
@@ -285,6 +287,7 @@ async def update_image_tags(
image_id: uuid.UUID, image_id: uuid.UUID,
body: dict, body: dict,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: Identity = Depends(require_auth),
): ):
image_repo = ImageRepository(db) image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id) image = await image_repo.get_by_id(image_id)
@@ -314,6 +317,7 @@ async def delete_image(
image_id: uuid.UUID, image_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage), storage: StorageBackend = Depends(get_storage),
_: Identity = Depends(require_auth),
): ):
image_repo = ImageRepository(db) image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id) image = await image_repo.get_by_id(image_id)

View File

@@ -16,6 +16,7 @@ dependencies = [
"pydantic-settings>=2.2", "pydantic-settings>=2.2",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"pillow>=10.0", "pillow>=10.0",
"PyJWT>=2.8",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -32,7 +33,10 @@ target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"] select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [] ignore = [
"B008", # FastAPI Depends/File/Form in function signatures — intentional
"B904", # raise-without-from inside except — HTTPException re-raise pattern
]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
@@ -43,3 +47,11 @@ testpaths = ["tests"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["app*"] include = ["app*"]
[dependency-groups]
dev = [
"anyio>=4.13.0",
"httpx>=0.28.1",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]

View File

@@ -1,12 +1,26 @@
import os
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
# Provide required settings for the test environment before any app imports resolve them
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.main import app from app.main import app
from app.config import get_settings from app.config import get_settings
from app.database import Base from app.database import Base
from app.dependencies import get_db, get_storage, get_auth from app.dependencies import get_db, get_storage, get_auth
from app.auth.jwt_provider import JWTAuthProvider
# Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear()
_TEST_JWT_SECRET = os.environ["JWT_SECRET_KEY"]
_TEST_OWNER_USERNAME = os.environ["OWNER_USERNAME"]
_TEST_OWNER_PASSWORD = os.environ["OWNER_PASSWORD"]
@pytest_asyncio.fixture(scope="session", loop_scope="session") @pytest_asyncio.fixture(scope="session", loop_scope="session")
@@ -57,3 +71,40 @@ async def client(db_session):
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def jwt_auth_provider() -> JWTAuthProvider:
return JWTAuthProvider(
secret_key=_TEST_JWT_SECRET,
expiry_seconds=3600,
owner_username=_TEST_OWNER_USERNAME,
owner_password=_TEST_OWNER_PASSWORD,
)
@pytest_asyncio.fixture
async def authed_client(db_session, jwt_auth_provider):
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend()
auth = jwt_auth_provider
async def override_db():
yield db_session
def override_storage():
return storage
def override_auth():
return auth
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_storage] = override_storage
app.dependency_overrides[get_auth] = override_auth
valid_token = auth.create_token()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c, valid_token
app.dependency_overrides.clear()

View File

@@ -0,0 +1,51 @@
import pytest
_VALID_CREDS = {"username": "testowner", "password": "testpassword"}
@pytest.mark.asyncio
async def test_login_success(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json=_VALID_CREDS)
assert response.status_code == 200
body = response.json()
assert isinstance(body.get("access_token"), str)
assert len(body["access_token"]) > 0
assert body.get("token_type") == "bearer"
assert body.get("expires_in", 0) > 0
@pytest.mark.asyncio
async def test_login_wrong_password(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "testowner", "password": "wrongpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_wrong_username(authed_client):
client, _ = authed_client
response = await client.post(
"/api/v1/auth/token",
json={"username": "notowner", "password": "testpassword"},
)
assert response.status_code == 401
assert response.json().get("code") == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_missing_password(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"username": "testowner"})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_missing_username(authed_client):
client, _ = authed_client
response = await client.post("/api/v1/auth/token", json={"password": "testpassword"})
assert response.status_code == 422

View File

@@ -0,0 +1,95 @@
"""
Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import uuid
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x02"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_upload_without_token_returns_401(authed_client):
client, _ = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_upload_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (200, 201)
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/images/{fake_id}")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_delete_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.delete(
f"/api/v1/images/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/images/{fake_id}/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@pytest.mark.asyncio
async def test_patch_tags_with_valid_token_succeeds(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.patch(
f"/api/v1/images/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200

View File

@@ -0,0 +1,71 @@
"""
US3 regression tests: all read endpoints must remain accessible without a token
even after require_auth is applied to write endpoints.
"""
import io
import uuid
import pytest
def _minimal_jpeg() -> bytes:
return (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x03"
b"\xff\xd9"
)
@pytest.mark.asyncio
async def test_list_images_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/images")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_image_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_file_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_serve_thumbnail_without_token_is_200(authed_client):
client, token = authed_client
data = _minimal_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_list_tags_without_token_is_200(authed_client):
client, _ = authed_client
response = await client.get("/api/v1/tags")
assert response.status_code == 200

View File

@@ -2,14 +2,27 @@ import os
import pytest import pytest
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
"S3_BUCKET_NAME": "test-bucket",
"S3_ACCESS_KEY_ID": "key",
"S3_SECRET_ACCESS_KEY": "secret",
"S3_REGION": "us-east-1",
"API_BASE_URL": "http://localhost:8000",
"JWT_SECRET_KEY": "test-secret",
"OWNER_USERNAME": "admin",
"OWNER_PASSWORD": "password",
}
def _apply_env(monkeypatch, extra=None):
for k, v in {**_BASE_ENV, **(extra or {})}.items():
monkeypatch.setenv(k, v)
def test_settings_load_from_env(monkeypatch): def test_settings_load_from_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db") _apply_env(monkeypatch)
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
# Import inside test to pick up monkeypatched env # Import inside test to pick up monkeypatched env
import importlib import importlib
@@ -20,17 +33,13 @@ def test_settings_load_from_env(monkeypatch):
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db" assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
assert s.s3_bucket_name == "test-bucket" assert s.s3_bucket_name == "test-bucket"
assert s.max_upload_bytes == 52428800 # default assert s.max_upload_bytes == 52428800 # default
assert s.jwt_secret_key == "test-secret"
assert s.jwt_expiry_seconds == 86400 # default
assert s.owner_username == "admin"
def test_settings_max_upload_bytes_override(monkeypatch): def test_settings_max_upload_bytes_override(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db") _apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
monkeypatch.setenv("S3_REGION", "us-east-1")
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
import importlib import importlib
import app.config as config_module import app.config as config_module
@@ -38,3 +47,14 @@ def test_settings_max_upload_bytes_override(monkeypatch):
s = config_module.Settings() s = config_module.Settings()
assert s.max_upload_bytes == 10485760 assert s.max_upload_bytes == 10485760
def test_settings_jwt_expiry_override(monkeypatch):
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
assert s.jwt_expiry_seconds == 3600

View File

@@ -0,0 +1,97 @@
import time
import pytest
import jwt as pyjwt
from fastapi import HTTPException
from app.auth.jwt_provider import JWTAuthProvider
SECRET = "test-secret-key"
USERNAME = "owner"
PASSWORD = "hunter2"
def make_provider(**kwargs) -> JWTAuthProvider:
defaults = dict(
secret_key=SECRET,
expiry_seconds=3600,
owner_username=USERNAME,
owner_password=PASSWORD,
)
return JWTAuthProvider(**{**defaults, **kwargs})
def test_create_token_is_valid_jwt():
provider = make_provider()
token = provider.create_token()
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
assert payload["sub"] == "owner"
assert "iat" in payload
assert "exp" in payload
@pytest.mark.asyncio
async def test_get_identity_returns_owner():
provider = make_provider()
token = provider.create_token()
identity = await provider.get_identity(f"Bearer {token}")
assert identity.id == "owner"
assert identity.anonymous is False
@pytest.mark.asyncio
async def test_get_identity_raises_on_expired_token():
provider = make_provider(expiry_seconds=-1)
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_wrong_key():
provider = make_provider()
other = make_provider(secret_key="different-secret")
token = other.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(f"Bearer {token}")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_garbage():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity("Bearer not.a.real.token")
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_header():
provider = make_provider()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(None)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_get_identity_raises_on_missing_bearer_prefix():
provider = make_provider()
token = provider.create_token()
with pytest.raises(HTTPException) as exc_info:
await provider.get_identity(token)
assert exc_info.value.status_code == 401
def test_verify_credentials_true():
provider = make_provider()
assert provider.verify_credentials(USERNAME, PASSWORD) is True
def test_verify_credentials_false_wrong_password():
provider = make_provider()
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
def test_verify_credentials_false_wrong_username():
provider = make_provider()
assert provider.verify_credentials("notowner", PASSWORD) is False

1594
api/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: JWT Bearer Token Authentication
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
All items pass. Spec is ready for `/speckit-plan`.

View File

@@ -0,0 +1,120 @@
# API Contracts: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
All routes remain under `/api/v1/`. Error responses use the existing envelope:
`{ "detail": "<human message>", "code": "<machine code>" }`.
---
## New Endpoint
### `POST /api/v1/auth/token`
Issues a bearer token for the owner after verifying credentials.
**Request**
```
Content-Type: application/json
{
"username": "<string>",
"password": "<string>"
}
```
Both fields are required. A missing or empty field returns `422`.
**Success response**`200 OK`
```json
{
"access_token": "<jwt-string>",
"token_type": "bearer",
"expires_in": 86400
}
```
`expires_in` reflects the configured `JWT_EXPIRY_SECONDS` value.
**Failure responses**
| Status | Code | When |
|---|---|---|
| `401` | `invalid_credentials` | Username or password is wrong |
| `422` | (FastAPI default) | Missing or malformed request body |
---
## Changed Endpoints — Access Control
The following endpoints now require a valid bearer token. Requests without
a token, or with an invalid/expired token, receive a `401`.
| Method | Path | Was | Now |
|---|---|---|---|
| `POST` | `/api/v1/images` | Public | **Protected** |
| `DELETE` | `/api/v1/images/{id}` | Public | **Protected** |
| `PATCH` | `/api/v1/images/{id}/tags` | Public | **Protected** |
**Bearer token transmission**
The client MUST include the token in the `Authorization` header:
```
Authorization: Bearer <access_token>
```
**401 response shape** (returned by all three protected endpoints when
authentication fails):
```json
{
"detail": "Authentication required",
"code": "unauthorized"
}
```
---
## Unchanged Endpoints — Remain Public
The following endpoints require no token and must continue to accept requests
without an `Authorization` header:
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/v1/images` | List / filter images |
| `GET` | `/api/v1/images/{id}` | Get image metadata |
| `GET` | `/api/v1/images/{id}/file` | Serve original image |
| `GET` | `/api/v1/images/{id}/thumbnail` | Serve image thumbnail |
| `GET` | `/api/v1/tags` | List / search tags |
| `GET` | `/api/v1/health` | Health check |
Sending a token on these endpoints is harmless (the server ignores it) but
is not required.
---
## Token Validation Rules
The API validates tokens using the following rules, in order:
1. The `Authorization` header value MUST begin with `Bearer ` (case-sensitive).
2. The token MUST be a valid HS256-signed JWT (verified against `JWT_SECRET_KEY`).
3. The `exp` claim MUST be in the future (at time of request receipt).
4. Any failure in steps 13 returns `401 unauthorized`.
---
## UI Route Contracts
These are Angular SPA routes affected by this feature.
| Route | Guard | Behaviour |
|---|---|---|
| `/login` | None | Login form; redirects to `returnUrl` or `/` on success |
| `/upload` | `authGuard` | Redirects to `/login?returnUrl=/upload` if not authenticated |
| `/images/:id` | None | Always accessible; tag-edit and delete controls visible only when authenticated |
| `/` | None | Always accessible (library) |

View File

@@ -0,0 +1,187 @@
# Data Model: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
---
## Database Changes
**None.** JWTs are stateless bearer tokens. The API validates them by
cryptographic signature and embedded expiry claim on each request. No token
storage, session table, or blocklist is introduced in Phase 2.
---
## Configuration Schema (new env vars)
Four new environment variables are added to `api/app/config.py` and
`.env.example`.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
| `JWT_SECRET_KEY` | `str` | Yes | — | HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent). |
| `JWT_EXPIRY_SECONDS` | `int` | No | `86400` | Token lifetime in seconds (24 h). |
| `OWNER_USERNAME` | `str` | Yes | — | Login username for the single owner account. |
| `OWNER_PASSWORD` | `str` | Yes | — | Login password for the single owner account. |
These values are loaded via `pydantic-settings` (`BaseSettings`) alongside the
existing database and S3 settings. `JWT_SECRET_KEY`, `OWNER_USERNAME`, and
`OWNER_PASSWORD` have no defaults and will raise a validation error at startup
if absent, providing a clear "misconfigured" failure rather than a silent
security hole.
---
## Token Structure
A JWT issued by the login endpoint carries the following claims.
| Claim | Type | Value |
|---|---|---|
| `sub` | string | `"owner"` — fixed identifier for the single owner |
| `iat` | integer | Unix epoch seconds at time of issuance |
| `exp` | integer | `iat + JWT_EXPIRY_SECONDS` |
Algorithm: `HS256` (HMAC-SHA256). Secret: `JWT_SECRET_KEY` setting.
The token is opaque to the client. The Angular SPA stores it in
`sessionStorage` and transmits it as `Authorization: Bearer <token>` on every
request.
---
## Module and Interface Changes
### `api/app/auth/provider.py` — updated interface
The `get_identity()` method gains an `authorization` parameter — the raw value
of the `Authorization` HTTP header (or `None` if the header is absent).
```
AuthProvider (abstract)
get_identity(authorization: str | None) -> Identity
Identity (dataclass)
id: str
anonymous: bool = True
```
### `api/app/auth/noop.py` — no behavioural change
`NoOpAuthProvider.get_identity()` continues to return the static anonymous
identity regardless of the `authorization` argument. The signature is updated
to match the new interface.
### `api/app/auth/jwt_provider.py` — new module
```
JWTAuthProvider (AuthProvider)
__init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
get_identity(authorization: str | None) -> Identity
- Parses "Bearer <token>" from authorization header
- Decodes and validates the JWT (signature + exp)
- Returns Identity(id="owner", anonymous=False) on success
- Raises HTTPException 401 with code "unauthorized" on any failure
create_token() -> str
- Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
- Returns the encoded token string
verify_credentials(username: str, password: str) -> bool
- Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
- Uses secrets.compare_digest to prevent timing attacks
- Returns True on match, False otherwise
```
### `api/app/dependencies.py` — new `require_auth` dependency
```
require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth)
) -> Identity
- Calls auth.get_identity(authorization)
- Raises HTTPException 401 if identity.anonymous is True
- Returns the Identity on success
```
Protected routes inject `identity: Identity = Depends(require_auth)` and do
not need to perform any additional auth checks — the dependency raises before
the route body executes if authentication fails.
### `api/app/routers/auth.py` — new router
```
POST /api/v1/auth/token
Request body: LoginRequest { username: str, password: str }
Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }
```
---
## Angular Module Changes
### `ui/src/app/auth/auth.service.ts` — new service
```
AuthService
TOKEN_KEY = 'auth_token' (sessionStorage key)
login(username: string, password: string): Observable<void>
- POST /api/v1/auth/token
- On success: stores access_token in sessionStorage, emits completion
- On 401: propagates error for the component to handle
logout(): void
- Removes token from sessionStorage
getToken(): string | null
- Returns stored token or null
isAuthenticated(): boolean
- Returns true if getToken() is non-null
```
### `ui/src/app/auth/auth.interceptor.ts` — new functional interceptor
Attaches `Authorization: Bearer <token>` to every outbound `HttpRequest` if
`AuthService.getToken()` returns a non-null value. Requests without a token
are passed through unmodified.
### `ui/src/app/auth/auth.guard.ts` — new route guard
Functional `CanActivateFn`. If `AuthService.isAuthenticated()` is `false`,
redirects to `/login?returnUrl=<current-url>`. Otherwise allows navigation.
### `ui/src/app/login/login.component.ts` — new component
Route: `/login`
```
LoginComponent
Fields: username (required), password (required)
On submit:
- Calls AuthService.login()
- On success: navigates to returnUrl query param, or '/' if absent
- On 401: displays inline error "Invalid username or password"
- On other error: displays generic error message
Shows loading state while request is in flight
```
### `ui/src/app/detail/detail.component.ts` — updated
Injects `AuthService`. Hides tag-edit input and delete button when
`auth.isAuthenticated()` is `false`. Shows them when authenticated.
Read-only view (image display, tag chips) is always visible.
### `ui/src/app/app.routes.ts` — updated
`/upload` route gains `canActivate: [authGuard]`. `/login` route is added
(unguarded). All other routes are unchanged.
### `ui/src/app/app.config.ts` — updated
`provideHttpClient()` becomes
`provideHttpClient(withInterceptors([authInterceptor]))`.

View File

@@ -0,0 +1,355 @@
# Implementation Plan: JWT Bearer Token Authentication
**Branch**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/004-jwt-bearer-auth/spec.md`
## Summary
Implement Phase 2 of the progressive auth plan (constitution §2.4): replace
the no-op `AuthProvider` with a `JWTAuthProvider` that issues and validates
HS256 bearer tokens. Upload, delete, and tag-update endpoints become
protected; all read endpoints stay public. The Angular SPA gains a login page,
a session-scoped token store, an HTTP interceptor, and a route guard for the
upload page.
No database migration is required — tokens are stateless. A single new PyJWT
dependency is added to the API. The `AuthProvider` interface gains a parameter
so the JWT provider can access the `Authorization` header; `NoOpAuthProvider`
is updated to match but its behaviour is unchanged.
Changes span: `api/app/config.py` (4 new settings), `api/app/auth/provider.py`
(interface update), `api/app/auth/jwt_provider.py` (new), `api/app/routers/auth.py`
(new), `api/app/dependencies.py` (new `require_auth` dependency), three route
updates in `api/app/routers/images.py`, and on the UI side: a new `AuthService`,
`AuthInterceptor`, `AuthGuard`, and `LoginComponent`, plus small updates to
`app.routes.ts`, `app.config.ts`, and `detail.component.ts`.
## Technical Context
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, PyJWT (new), pydantic-settings, Angular
**Storage**: PostgreSQL (no schema changes); S3-compatible (no changes)
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — FastAPI API + Angular SPA
**Performance Goals**: Login round-trip under 15 s on local network (well within
reach; no database lookup, only in-memory credential comparison + JWT signing)
**Constraints**: Stateless tokens — no server-side session storage; single owner
account; no token revocation in v1
**Scale/Scope**: Single-user personal application; auth is a deployment-time
configuration concern, not a runtime management concern
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
| Principle | Check | Status |
|---|---|---|
| §2.1 Separation of concerns | JWT logic lives only in `JWTAuthProvider`; routes orchestrate; UI knows nothing about signing | ✅ |
| §2.2 Dependency direction | UI → API only; no upward imports introduced | ✅ |
| §2.3 Storage abstraction | No change to storage layer | ✅ |
| §2.4 Auth abstraction | `JWTAuthProvider` is a second `AuthProvider` implementation — exactly the pattern §2.4 designed for | ✅ |
| §2.5 DB abstraction | No DB changes; stateless JWTs require no session table | ✅ |
| §2.6 No speculative abstraction | No new interfaces; only a second concrete implementation of an already-planned interface | ✅ |
| §3.1 API versioning | New route at `/api/v1/auth/token` | ✅ |
| §3.3 Error shape | `401` uses `{"detail": "...", "code": "unauthorized"}` / `"invalid_credentials"` | ✅ |
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
| §5.2 Test pyramid | Unit tests for JWT logic; integration tests for all changed routes | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular specs colocated with components | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
| §7.2 Env configuration | `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`, `OWNER_USERNAME`, `OWNER_PASSWORD` added as env vars | ✅ |
| §8 Scope boundaries | §8 lists "Username/password auth (planned Phase 2)" as deferred. This feature IS Phase 2; the deferral is now lifted. All other §8 items remain deferred. | ✅ |
**Post-design re-check**: All gates still pass after Phase 1 design.
## Project Structure
### Documentation (this feature)
```text
specs/004-jwt-bearer-auth/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 decisions
├── data-model.md # Module and interface changes
├── contracts/
│ └── api.md # New endpoint + changed access control
├── checklists/
│ └── requirements.md # Spec quality checklist
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Files changed or created
```text
api/
├── pyproject.toml # Add PyJWT dependency
├── app/
│ ├── config.py # Add 4 new settings
│ ├── dependencies.py # Add require_auth dependency
│ ├── auth/
│ │ ├── provider.py # get_identity(authorization) signature
│ │ ├── noop.py # Updated signature, same behaviour
│ │ └── jwt_provider.py # NEW — JWTAuthProvider
│ └── routers/
│ ├── auth.py # NEW — POST /auth/token
│ └── images.py # Protect upload, delete, patch-tags
├── main.py # Register auth router
└── .env.example # Add 4 new vars
ui/
└── src/
└── app/
├── auth/
│ ├── auth.service.ts # NEW
│ ├── auth.service.spec.ts # NEW
│ ├── auth.interceptor.ts # NEW
│ ├── auth.interceptor.spec.ts # NEW
│ └── auth.guard.ts # NEW
├── login/
│ ├── login.component.ts # NEW
│ ├── login.component.html # NEW
│ └── login.component.spec.ts # NEW
├── detail/
│ └── detail.component.ts # Conditionally show edit/delete
├── app.routes.ts # Add /login; guard /upload
├── app.config.ts # Register authInterceptor
└── app.component.ts # Add logout button (visible when authenticated)
```
## Milestones
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
> the failing test(s) first, confirm they fail, then implement until they pass.
---
### M1 — JWT provider: token signing and validation
**Goal**: A tested `JWTAuthProvider` that can mint tokens and validate bearer
tokens from an `Authorization` header. The `AuthProvider` interface is updated;
`NoOpAuthProvider` is kept compatible.
**Deliverables**:
- Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`
- Update `api/app/config.py`:
- `jwt_secret_key: str` (required — no default; validated by pydantic)
- `jwt_expiry_seconds: int = 86400`
- `owner_username: str` (required)
- `owner_password: str` (required)
- Update `api/app/auth/provider.py`: `get_identity(self, authorization: str | None) -> Identity`
- Update `api/app/auth/noop.py`: match new signature; behaviour unchanged
- Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider`:
- `create_token() -> str` — mint HS256 JWT with `sub="owner"`, `iat`, `exp`
- `verify_credentials(username, password) -> bool``secrets.compare_digest`
- `get_identity(authorization) -> Identity` — parse `"Bearer <token>"`,
decode JWT, return `Identity(id="owner", anonymous=False)` on success, or
raise `HTTPException(401, {"detail": "...", "code": "unauthorized"})` on
any failure (missing header, invalid format, bad signature, expired)
**Unit tests** in `api/tests/unit/test_jwt_auth.py` (write first, confirm fail):
- `test_create_token_is_valid_jwt` — minted token decodes with PyJWT without error
- `test_get_identity_returns_owner` — valid token → non-anonymous Identity
- `test_get_identity_raises_on_expired_token` — token with past `exp` → 401
- `test_get_identity_raises_on_wrong_key` — token signed with different key → 401
- `test_get_identity_raises_on_garbage` — random string as token → 401
- `test_get_identity_raises_on_missing_header``authorization=None` → 401
- `test_get_identity_raises_on_missing_bearer_prefix``"token-without-prefix"` → 401
- `test_verify_credentials_true` — correct username + password → True
- `test_verify_credentials_false_wrong_password` — wrong password → False
- `test_verify_credentials_false_wrong_username` — wrong username → False
**Done criterion**: All 10 unit tests pass; `ruff check api/` passes; existing
tests unaffected.
---
### M2 — Login endpoint
**Goal**: `POST /api/v1/auth/token` issues a token for valid credentials and
rejects invalid ones.
**Deliverables**:
- Create `api/app/routers/auth.py`:
- `LoginRequest` Pydantic model: `username: str`, `password: str`
- `POST /auth/token` route: call `auth_provider.verify_credentials()`; on
success call `auth_provider.create_token()` and return `TokenResponse`
`{access_token, token_type="bearer", expires_in}`; on failure raise
`401 invalid_credentials`
- Update `api/app/dependencies.py`: instantiate `JWTAuthProvider` (reading
settings) instead of `NoOpAuthProvider` in `get_auth()`
- Update `api/app/main.py`: register `auth.router` under `/api/v1`
**Integration tests** in `api/tests/integration/test_auth.py` (write first):
- `test_login_success` — POST valid creds → 200, response contains
`access_token` (non-empty string), `token_type="bearer"`, `expires_in > 0`
- `test_login_wrong_password` — correct username, wrong password → 401,
code `invalid_credentials`
- `test_login_wrong_username` — wrong username → 401, code `invalid_credentials`
- `test_login_missing_password` — body `{"username": "x"}` → 422
- `test_login_missing_username` — body `{"password": "x"}` → 422
**Test infrastructure note**: The integration test `conftest.py` currently
overrides `get_auth` with `NoOpAuthProvider`. Tests for the auth endpoint
need to override with a test `JWTAuthProvider` (a real provider with test
credentials). Add a `jwt_auth_provider` fixture and an `authed_client` fixture
(with a bearer token) to `conftest.py` for use in M3.
**Done criterion**: All 5 new tests pass; all existing tests pass (the
`NoOpAuthProvider` override in `conftest.py` means existing tests are
unaffected by switching the production `get_auth()` to `JWTAuthProvider`).
---
### M3 — Protected endpoints
**Goal**: Upload, delete, and patch-tags reject unauthenticated requests.
All public endpoints remain accessible without a token.
**Deliverables**:
- Add `require_auth` to `api/app/dependencies.py`:
```python
async def require_auth(
authorization: str | None = Header(None, alias="Authorization"),
auth: AuthProvider = Depends(get_auth),
) -> Identity:
identity = await auth.get_identity(authorization)
if identity.anonymous:
raise HTTPException(
status_code=401,
detail={"detail": "Authentication required", "code": "unauthorized"},
)
return identity
```
- In `api/app/routers/images.py`:
- `upload_image()`: add `_: Identity = Depends(require_auth)` parameter
- `delete_image()`: add `_: Identity = Depends(require_auth)` parameter
- `update_image_tags()`: add `_: Identity = Depends(require_auth)` parameter
- Remove the now-redundant `auth: AuthProvider = Depends(get_auth)` from
`upload_image()` (it was injected but never called; `require_auth` subsumes it)
**Integration tests** — add to existing test files (write failing tests first):
In `api/tests/integration/test_upload.py`:
- `test_upload_without_token_returns_401` — POST without `Authorization` → 401, code `unauthorized`
- `test_upload_with_valid_token_succeeds` — POST with valid bearer token → 200/201
In `api/tests/integration/test_delete.py`:
- `test_delete_without_token_returns_401` — DELETE without token → 401
- `test_delete_with_valid_token_succeeds` — DELETE with valid token → 204
In `api/tests/integration/test_serving.py` (tag update lives here conceptually
but the route is in images; add to `test_upload.py` or a new `test_tags.py`):
- `test_patch_tags_without_token_returns_401` — PATCH without token → 401
- `test_patch_tags_with_valid_token_succeeds` — PATCH with valid token → 200
Public endpoint regression tests (confirm no 401 regression):
- `test_list_images_without_token_is_200` — GET /images → 200 (no auth)
- `test_get_image_without_token_is_200` — GET /images/{id} → 200
- `test_serve_file_without_token_is_200` — GET /images/{id}/file → 200
- `test_serve_thumbnail_without_token_is_200` — GET /images/{id}/thumbnail → 200
- `test_list_tags_without_token_is_200` — GET /tags → 200
**conftest.py update**: The `client` fixture already overrides `get_auth` with
`NoOpAuthProvider`, so all existing tests (which do not send tokens) continue
to pass without modification. The new `authed_client` fixture (from M2) uses
a `JWTAuthProvider` override and injects a valid token via the `Authorization`
header.
**Done criterion**: All new tests pass; all existing tests continue to pass.
---
### M4 — UI: `AuthService`, `AuthInterceptor`, `AuthGuard`, `LoginComponent`
**Goal**: Angular has a working login flow. The upload page is protected.
The detail page shows/hides write controls based on auth state.
**Deliverables**:
`ui/src/app/auth/auth.service.ts`:
- `TOKEN_KEY = 'auth_token'`
- `login(username, password): Observable<void>` — POST to `/api/v1/auth/token`,
store `access_token` in `sessionStorage` on success
- `logout(): void` — `sessionStorage.removeItem(TOKEN_KEY)`
- `getToken(): string | null`
- `isAuthenticated(): boolean`
`ui/src/app/auth/auth.interceptor.ts` (functional interceptor):
- If `AuthService.getToken()` returns non-null, clone request with
`Authorization: Bearer <token>` header; otherwise pass through
`ui/src/app/auth/auth.guard.ts` (`CanActivateFn`):
- If not authenticated: `router.createUrlTree(['/login'], {queryParams: {returnUrl: state.url}})`
- If authenticated: `true`
`ui/src/app/login/login.component.ts`:
- Reactive form with `username` (required) and `password` (required) controls
- `onSubmit()`: calls `AuthService.login()`; on success navigates to `returnUrl`
query param (default `/`); on error displays inline "Invalid username or password"
- Loading state while request is in flight; button disabled during loading
`ui/src/app/detail/detail.component.ts` (update):
- Inject `AuthService`
- In template: `*ngIf="auth.isAuthenticated()"` wraps the tag-edit input and
the delete button
`ui/src/app/app.routes.ts` (update):
- Add `{ path: 'login', loadComponent: () => import('./login/login.component').then(...) }`
- Add `canActivate: [authGuard]` to the `/upload` route
`ui/src/app/app.config.ts` (update):
- `provideHttpClient(withInterceptors([authInterceptor]))`
**Angular unit tests** (write first, confirm fail):
`ui/src/app/auth/auth.service.spec.ts`:
- `test_login_stores_token` — mock HTTP, verify `sessionStorage` has token after login
- `test_logout_clears_token` — store a token, logout, verify `sessionStorage` empty
- `test_isAuthenticated_true_when_token_present` — set token, assert true
- `test_isAuthenticated_false_when_no_token` — clear sessionStorage, assert false
`ui/src/app/auth/auth.interceptor.spec.ts`:
- `test_adds_auth_header_when_authenticated` — authenticated state, outbound
request has `Authorization: Bearer <token>` header
- `test_no_auth_header_when_not_authenticated` — unauthenticated, outbound
request has no `Authorization` header
`ui/src/app/login/login.component.spec.ts`:
- `test_submit_calls_auth_service_login` — spy on `AuthService.login`, submit
form, verify called with correct username/password
- `test_navigates_on_success` — mock successful login, verify router navigate called
- `test_shows_error_on_failure` — mock 401, verify error message visible
**Done criterion**: Angular build clean; all Angular tests pass.
---
### M5 — `.env.example` update and final validation
**Goal**: New env vars documented; full test suite green.
**Deliverables**:
- Update `.env.example`: add `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`,
`OWNER_USERNAME`, `OWNER_PASSWORD` with example values and comments
- Run `pytest api/ -v` — confirm all tests pass (expected: existing ~57 tests
+ ~20 new tests ≈ 77 total)
- Run `ruff check api/ && ruff format --check api/` — zero violations
- Run `ng test` (inside UI container) — all Angular tests pass
- Run `ng build` — Angular build succeeds
**Done criterion**: All tests pass; both linters pass; `docker compose up`
starts the full stack and the login flow works end-to-end in the browser.
## Post-design Constitution Re-check
| Principle | Verdict |
|---|---|
| §2.4 Auth abstraction | `JWTAuthProvider` is a drop-in second implementation; business logic in routes is unchanged except for the added `require_auth` dependency | ✅ |
| §2.6 No speculative abstraction | No new interfaces; `JWTAuthProvider` is concrete and implements an already-planned interface | ✅ |
| §3.3 Error shape | `401` envelope uses `code` field throughout | ✅ |
| §5.1 TDD | Failing tests precede every implementation milestone | ✅ |
| §7.2 Env config | All four new settings come from env vars; no hardcoded credentials | ✅ |
All gates pass. Feature is ready for `/speckit-tasks`.

View File

@@ -0,0 +1,185 @@
# Research: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
---
## Decision 1 — JWT library
**Decision**: `PyJWT>=2.8`
**Rationale**: The project needs only HS256 signing with a single symmetric
secret key — the simplest possible JWT profile. `PyJWT` is the de-facto
standard Python JWT library for this use case: no additional crypto
dependencies, actively maintained, wide community adoption. `python-jose` was
the alternative; it has broader JOSE/JWE support but has had maintenance gaps
and brings extra dependencies that we do not need.
For Phase 3 (OIDC), token issuance is replaced by the external identity
provider. The `JWTAuthProvider` will be replaced by an OIDC-aware provider;
the library choice for Phase 2 does not constrain Phase 3.
**Alternatives considered**: `python-jose[cryptography]` — wider JOSE support
but heavier dependency tree and slower maintenance cadence. Rejected.
---
## Decision 2 — Password storage for the single owner account
**Decision**: Store plaintext `OWNER_USERNAME` and `OWNER_PASSWORD` in
environment variables; compare at login time using `secrets.compare_digest`
to prevent timing attacks.
**Rationale**: This is a single-user self-hosted application accessed over a
trusted local network. The password is already known to the person deploying
the application (they set it). Bcrypt pre-hashing would require operators to
run a separate tool to generate the hash before setting the env var, adding
friction with no meaningful security benefit for this threat model. In Phase 3
the owner credentials are replaced by an external OIDC provider entirely, so
this is a temporary mechanism with limited lifetime.
`secrets.compare_digest` is used instead of `==` to prevent any theoretical
timing oracle.
**Alternatives considered**: `passlib[bcrypt]` with a pre-hashed password env
var — more "correct" in absolute terms but adds operator complexity for a
single-user local app. Rejected.
---
## Decision 3 — JWT algorithm and claims
**Decision**: HS256 (HMAC-SHA256) with claims: `sub` (fixed string `"owner"`),
`iat` (issued-at epoch seconds), `exp` (expiry epoch seconds).
**Rationale**: HS256 is symmetric — a single `JWT_SECRET_KEY` env var is used
for both signing and verification. This is appropriate for a single-server
deployment where only the API ever validates tokens. RS256 (asymmetric) would
be needed if a second service needed to verify tokens independently; that is
not the case here and would be added complexity.
The `sub` claim carries the owner identifier. `exp` enables configurable
expiry. `iat` is included for auditability.
**Alternatives considered**: RS256 — appropriate when multiple services verify
tokens. Overkill for this single-server deployment. Rejected.
---
## Decision 4 — Login endpoint request format
**Decision**: JSON body `{"username": "...", "password": "..."}` at
`POST /api/v1/auth/token`. Response: `{"access_token": "...", "token_type": "bearer", "expires_in": <seconds>}`.
**Rationale**: The OAuth2 `application/x-www-form-urlencoded` format
(`grant_type=password, username, password`) is the spec-compliant form for
the Resource Owner Password Credentials grant. However, we are not building a
full OAuth2 authorization server — this is a simplified login endpoint for a
single-user SPA. A JSON body is simpler to consume from Angular's
`HttpClient`, avoids `URLSearchParams` boilerplate, and does not mislead
consumers into thinking this is a full OAuth2 endpoint.
The response shape (`access_token`, `token_type`) follows the OAuth2 bearer
token response convention because Phase 3 (OIDC) will also produce tokens in
this shape — the Angular `AuthService` does not need to change its
token-parsing logic.
**Alternatives considered**: OAuth2 password grant form format — interoperable
but unnecessarily strict for this use case. Rejected.
---
## Decision 5 — `AuthProvider` interface evolution
**Decision**: Evolve `get_identity()` to accept a single optional string
argument: `async def get_identity(self, authorization: str | None) -> Identity`.
`NoOpAuthProvider` ignores the argument and returns the anonymous identity as
before. `JWTAuthProvider` parses `"Bearer <token>"`, validates the JWT, and
returns a non-anonymous `Identity`, or raises a `401` via `HTTPException` if
the token is invalid or expired.
A new `require_auth` dependency in `dependencies.py` calls
`auth.get_identity(authorization_header)` and raises `401` if the returned
identity is anonymous. Protected routes inject `Depends(require_auth)`.
Public routes continue to bypass auth entirely — they neither inject auth nor
call `get_identity`.
**Rationale**: Minimal interface change that preserves backward compatibility
(`NoOpAuthProvider` continues to work unchanged) while allowing the JWT
provider to access the request header cleanly through FastAPI's `Header`
dependency. An alternative would be injecting `Request` directly into the
provider, but that couples the provider to the ASGI framework; a string header
value keeps the provider framework-agnostic.
**Alternatives considered**: Pass `Request` to `get_identity()` — couples the
provider to FastAPI/ASGI. Rejected. Create a separate `validate_token(token)`
method — more interface surface, no clear benefit over the chosen approach.
Rejected.
---
## Decision 6 — Token storage in the browser
**Decision**: `sessionStorage` — tokens are discarded when the browser tab is
closed.
**Rationale**: `localStorage` persists across browser sessions and is
accessible to any JavaScript on the page, making it a wider XSS target.
`sessionStorage` is scoped to the tab and cleared on close, giving better
security for a shared or semi-public machine. For a personal app used by the
owner on their own machine, the loss of persistence across browser restarts is
a minor inconvenience that is well worth the security improvement.
`HttpOnly` cookies would be more secure still but require CSRF protection and
server-side session management, which conflicts with the stateless JWT design.
**Alternatives considered**: `localStorage` — persistent but wider XSS
exposure. Rejected. `HttpOnly` cookie — strongest XSS protection but requires
CSRF mitigation and session server state. Rejected.
---
## Decision 7 — Angular interceptor API
**Decision**: Functional HTTP interceptor registered via
`provideHttpClient(withInterceptors([authInterceptor]))` in `app.config.ts`.
The interceptor reads the token from `AuthService.getToken()` and adds
`Authorization: Bearer <token>` to every outbound request if a token is
present.
**Rationale**: Angular 17+ prefers functional interceptors over class-based
ones (`APP_INITIALIZER` / `HTTP_INTERCEPTORS` token). The functional pattern
integrates with standalone components and is the current idiomatic approach.
The interceptor attaches the token to all requests unconditionally (not just
protected endpoints) — the API ignores the header on public endpoints, so this
is safe and avoids the complexity of URL matching in the interceptor.
**Alternatives considered**: Class-based `HttpInterceptor` — legacy pattern,
not aligned with Angular 17+ standalone idiom. Rejected.
---
## Decision 8 — No database migration required
**Decision**: JWTs are stateless — no token storage or blocklist is introduced.
**Rationale**: The tokens carry their own expiry; the API validates them by
signature and expiry on each request. A token blocklist (for logout
invalidation) would require a database table and lookup on every protected
request, adding complexity disproportionate to the threat model of a
single-user local application. On logout, the Angular client discards the
token from `sessionStorage`; the token technically remains valid until its
`exp`, but since there is no other client that holds it, this is acceptable.
**Alternatives considered**: Token blocklist table — true server-side logout.
Out of scope for Phase 2; noted as a potential hardening step.
---
## Summary of new dependencies
| Package | Where | Purpose |
|---------|-------|---------|
| `PyJWT>=2.8` | `api/pyproject.toml` | JWT signing and verification |
No new UI dependencies — Angular's `HttpClient` and `Router` cover all
interceptor, guard, and HTTP needs.

View File

@@ -0,0 +1,253 @@
# Feature Specification: JWT Bearer Token Authentication
**Feature Branch**: `004-jwt-bearer-auth`
**Created**: 2026-05-03
**Status**: Draft
**Input**: User description: "Implement authentication with JWT bearer tokens. Image uploads, image deletion, and image tag updates should be protected. Non-authenticated users should still be able see images and tags, including searching for tags."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Log In and Receive a Token (Priority: P1)
The owner visits the application and logs in with their username and password.
On success, the application silently stores a credential that it will attach to
all future requests. The owner is taken to the library without needing to take
any further action.
**Why this priority**: Every protected action depends on having a valid
credential in hand. Without a working login flow, uploads, deletions, and tag
edits are all inaccessible.
**Independent Test**: Submit valid credentials via the login form. Confirm the
application navigates to the library and that subsequent protected actions
(upload, delete, tag edit) succeed without a second login prompt.
**Acceptance Scenarios**:
1. **Given** the owner is not logged in, **When** they open the application,
**Then** they are presented with a login form before they can reach any
protected action.
2. **Given** the owner submits their correct username and password, **When**
the submission is processed, **Then** they are authenticated, taken to the
library, and the credential is retained for the current session.
3. **Given** the owner submits an incorrect username or password, **When**
the submission is processed, **Then** an inline error is shown ("Invalid
credentials"), the credential is not stored, and the user remains on the
login page.
4. **Given** the owner is logged in and their session has expired, **When**
they attempt a protected action, **Then** they are redirected to the login
page and informed their session has ended.
5. **Given** the owner submits the login form with an empty username or
password field, **When** the submission is attempted, **Then** a validation
error is shown and no authentication request is made.
---
### User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
An authenticated owner can upload images, delete images, and update tags as
before. An unauthenticated visitor who attempts these actions is turned away.
**Why this priority**: This is the core security requirement. Until protected
actions reliably reject unauthenticated requests, the feature has not delivered
its value.
**Independent Test**: Without logging in, attempt to upload an image via the
API or UI. Confirm the attempt is rejected with an authentication error. Log in
and repeat — confirm the upload succeeds.
**Acceptance Scenarios**:
1. **Given** an authenticated owner, **When** they upload an image, delete an
image, or update tags on an image, **Then** the action succeeds as it did
before authentication was introduced.
2. **Given** an unauthenticated visitor, **When** they attempt to upload an
image via the UI or API, **Then** the request is rejected with a clear
authentication error and no image is stored.
3. **Given** an unauthenticated visitor, **When** they attempt to delete an
image via the UI or API, **Then** the request is rejected and the image
remains in the library.
4. **Given** an unauthenticated visitor, **When** they attempt to update tags
on an image via the UI or API, **Then** the request is rejected and the
tags are unchanged.
5. **Given** a request that carries a malformed, expired, or tampered
credential, **When** it reaches a protected endpoint, **Then** it is
rejected with an authentication error, not silently ignored.
---
### User Story 3 — Public Read Access (Priority: P1)
Unauthenticated visitors can browse the image library, view individual images,
and search or filter by tags — no login required for read-only use.
**Why this priority**: The user explicitly requires this behaviour. Forcing
login for read-only access would break the browse-without-an-account use case
and is not part of the security model for this application.
**Independent Test**: Without a credential, call the list-images, get-image,
serve-image, serve-thumbnail, and list-tags endpoints. All should return
successful responses.
**Acceptance Scenarios**:
1. **Given** an unauthenticated visitor, **When** they open the library,
**Then** all images and their tags are visible without a login prompt.
2. **Given** an unauthenticated visitor, **When** they apply tag filters,
**Then** the filtered results are shown without requiring authentication.
3. **Given** an unauthenticated visitor, **When** they open an image detail
page, **Then** the full-size image and its tags are displayed without a
login prompt.
4. **Given** an unauthenticated visitor, **When** they browse the tag list
or search for tags by prefix, **Then** results are returned without
requiring authentication.
---
### User Story 4 — Log Out (Priority: P2)
The owner can end their authenticated session. After logging out, the browser
no longer retains their credential and protected actions are blocked until
they log in again.
**Why this priority**: Important for shared or public machines, but secondary
to the core login and protection flows.
**Independent Test**: Log in, then log out. Attempt a protected action and
confirm it is rejected. Refresh the page and confirm the login screen is shown.
**Acceptance Scenarios**:
1. **Given** the owner is logged in, **When** they choose to log out,
**Then** their credential is discarded, they are returned to the login page,
and subsequent protected actions are rejected.
2. **Given** the owner has logged out, **When** they navigate directly to a
protected page (e.g., the upload form), **Then** they are redirected to the
login page.
---
### Edge Cases
- What happens when the owner's credential expires mid-session? → The next
protected action fails with an authentication error; the UI redirects to
the login page.
- What happens when an attacker replays a valid but expired credential? → The
request is rejected; expired credentials are never accepted.
- What happens when the login endpoint is called many times with wrong
credentials? → The spec does not require rate limiting or lockout in v1;
this is noted as a future hardening concern.
- What happens if the owner forgets their password? → Password reset is out of
scope for v1; credentials are set via server-side configuration only.
- What happens if the login endpoint is called while already authenticated? →
A new credential is issued; the old one may be discarded by the client.
- What happens when the UI receives a 401 on a read (public) endpoint? → This
should not occur; read endpoints must never require authentication.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST provide an endpoint that accepts a username and
password and, on success, returns a time-limited credential the client can
use to prove identity on subsequent requests.
- **FR-002**: The system MUST reject login attempts that supply an incorrect
username or password with a clear error; no credential is issued.
- **FR-003**: The following actions MUST be protected — they MUST reject any
request that does not carry a valid, unexpired credential:
- Upload an image
- Delete an image
- Update the tags on an image
- **FR-004**: The following actions MUST remain publicly accessible without any
credential:
- List images (with or without tag filters)
- Retrieve a single image's metadata
- Retrieve image file content
- Retrieve image thumbnail content
- List tags (with or without prefix filter)
- **FR-005**: Credentials MUST have a finite lifetime; a credential issued
before a configurable expiry window MUST be rejected.
- **FR-006**: The system MUST reject credentials that have been tampered with
or are otherwise invalid.
- **FR-007**: The UI MUST automatically attach the owner's credential to every
request that targets a protected action, without requiring the owner to
manually supply it each time.
- **FR-008**: The UI MUST redirect unauthenticated users to the login page when
they attempt to reach a protected action or page.
- **FR-009**: After a successful login, the UI MUST navigate the owner to the
library (or to the page they originally tried to reach, if redirected from
there).
- **FR-010**: The owner MUST be able to log out; after logout the credential is
discarded and protected actions are blocked until the owner logs in again.
- **FR-011**: The owner's username and password MUST be configurable without
changing application code (e.g., via environment variables or a configuration
file read at startup).
- **FR-012**: Only one set of owner credentials is required in v1; multi-user
support is explicitly out of scope.
### Key Entities
- **Credential**: A time-limited proof of identity issued to the owner after
successful login. Key attributes: subject (owner identifier), issued-at
timestamp, expiry timestamp, validity state (valid / expired / invalid).
- **Login Request**: The combination of username and password submitted by the
user to obtain a credential.
- **Protected Endpoint**: An API endpoint that MUST reject requests that lack a
valid credential.
- **Public Endpoint**: An API endpoint that MUST accept requests regardless of
whether a credential is present.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An unauthenticated visitor can browse the full image library and
tag list without being prompted to log in.
- **SC-002**: An unauthenticated attempt to upload, delete, or edit tags is
rejected every time — 0% of such attempts succeed.
- **SC-003**: An authenticated owner can complete a login-to-upload round trip
in under 15 seconds on a local network connection.
- **SC-004**: An expired credential is rejected on the first use after expiry;
no grace period or retry is granted.
- **SC-005**: After logging out, 100% of subsequent protected actions are
rejected until the owner logs in again.
- **SC-006**: The library, detail, tag list, and image-serving pages all load
correctly without a credential present.
## Assumptions
- A single owner account is sufficient for v1. No user registration flow is
required; credentials are set via environment variables or configuration at
deployment time.
- The application is accessed over a trusted local network connection for v1;
HTTPS is not mandated by this spec but is assumed for any production
deployment.
- Credential lifetime is configurable but defaults to 24 hours. The exact
value is a deployment decision, not a product requirement.
- Password reset, account management, and credential revocation are out of
scope for v1.
- Rate limiting and account lockout after repeated failed login attempts are
out of scope for v1; they are noted as future hardening work.
- The UI maintains the owner's credential for the duration of the browser
session. Behaviour after the browser is closed (persist vs. discard) follows
a secure default for the credential storage mechanism chosen during
implementation.
- This is Phase 2 of a planned three-phase auth progression (no-auth →
username/password → OIDC). The implementation MUST be structured so that
replacing the credential issuance and validation mechanism in Phase 3 does
not require changes to protected business logic.
- The detail page and upload form are considered "protected pages" in the UI
sense (require login to interact with write actions), but their read content
(viewing image, viewing tags) remains publicly accessible at the API level.

View File

@@ -0,0 +1,322 @@
# Tasks: JWT Bearer Token Authentication
**Input**: Design documents from `specs/004-jwt-bearer-auth/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
**TDD**: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.
**Organization**: Tasks follow user story priority order (US1 P1 → US2 P1 → US3 P1 → US4 P2). API milestones run first in each story, then Angular.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1US4)
- Include exact file paths in descriptions
## Path Conventions
```
api/app/ API source
api/tests/unit/ API unit tests
api/tests/integration/ API integration tests
ui/src/app/ Angular source
ui/src/app/auth/ New auth module
ui/src/app/login/ New login component
```
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: New dependency, updated config, updated interfaces, and test fixtures
that all user stories depend on. No user story work can begin until this is complete.
**⚠️ CRITICAL**: Complete all setup tasks before starting any user story phase.
- [X] T001 Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`; rebuild the Docker API image (`docker compose build api`) so PyJWT is available inside the container for all subsequent test runs
- [X] T002 Add four new settings to `api/app/config.py` (pydantic-settings `BaseSettings`): `jwt_secret_key: str` (required — no default, startup fails if absent), `jwt_expiry_seconds: int = 86400`, `owner_username: str` (required), `owner_password: str` (required); confirm `get_settings()` still loads from env vars via the existing `SettingsConfigDict`
- [X] T003 [P] Update `api/app/auth/provider.py`: change `get_identity(self)` to `get_identity(self, authorization: str | None) -> Identity`; this is a breaking interface change that will cause the `NoOpAuthProvider` to fail type-checking until T004 is done
- [X] T004 [P] Update `api/app/auth/noop.py`: match the new `get_identity(self, authorization: str | None) -> Identity` signature; the implementation still returns `_ANONYMOUS` and ignores `authorization`; run `pytest api/` to confirm all existing tests still pass (the conftest overrides get_auth so the interface change is invisible to running tests)
- [X] T005 Update `api/tests/integration/conftest.py`: add a `# TODO: complete after T007` comment block where the `jwt_auth_provider` and `authed_client` fixtures will live — do not add the import of `JWTAuthProvider` yet (it does not exist until T007 and would break `pytest api/` if imported now); the existing `client` fixture (with `NoOpAuthProvider`) must remain unchanged. After T007 is done, complete this task: add `jwt_auth_provider` fixture that constructs a `JWTAuthProvider` with test credentials, and `authed_client` fixture that overrides `get_auth` with that provider and yields `(client, valid_token)` where `valid_token = auth.create_token()`
**Checkpoint**: PyJWT installed, four new settings wired, interface updated, `NoOpAuthProvider` adapted, conftest ready. All existing tests still pass.
---
## Phase 2: Foundational (JWTAuthProvider — Blocks All Stories)
**Purpose**: The `JWTAuthProvider` must exist before the login endpoint (US1),
protected endpoints (US2), or public-read regression tests (US3) can be built.
**⚠️ CRITICAL**: All tasks in this phase must complete before any user story work begins.
### Tests for JWTAuthProvider (write FIRST — must FAIL before T007) ⚠️
- [X] T006 Write 10 unit tests in `api/tests/unit/test_jwt_auth.py` for the (not-yet-existing) `JWTAuthProvider`: `test_create_token_is_valid_jwt` (minted token decodes with PyJWT without error), `test_get_identity_returns_owner` (valid bearer token → non-anonymous `Identity` with `id="owner"`), `test_get_identity_raises_on_expired_token` (token with past `exp``HTTPException` 401), `test_get_identity_raises_on_wrong_key` (token signed with different secret → 401), `test_get_identity_raises_on_garbage` (random string as token value → 401), `test_get_identity_raises_on_missing_header` (`authorization=None` → 401), `test_get_identity_raises_on_missing_bearer_prefix` (`"token-without-prefix"` → 401), `test_verify_credentials_true` (matching username + password → `True`), `test_verify_credentials_false_wrong_password` (wrong password → `False`), `test_verify_credentials_false_wrong_username` (wrong username → `False`); run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 **fail** with `ImportError` or `AttributeError` (not-yet-implemented)
### JWTAuthProvider implementation
- [X] T007 Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider(AuthProvider)`: constructor takes `secret_key: str`, `expiry_seconds: int`, `owner_username: str`, `owner_password: str`; implement `create_token() -> str` using `jwt.encode({"sub": "owner", "iat": now, "exp": now + expiry_seconds}, secret_key, algorithm="HS256")`; implement `verify_credentials(username, password) -> bool` using `secrets.compare_digest`; implement `get_identity(authorization: str | None) -> Identity` — parse `"Bearer <token>"` (raise 401 `unauthorized` if missing or wrong prefix), decode with `jwt.decode()` (raise 401 on `ExpiredSignatureError`, `InvalidTokenError`, or any exception), return `Identity(id="owner", anonymous=False)` on success; run `pytest api/tests/unit/test_jwt_auth.py` and confirm all 10 pass; then run `pytest api/` to confirm no regressions
**Checkpoint**: `JWTAuthProvider` is fully implemented and tested. Login endpoint, protected-endpoint guard, and conftest fixtures can now be built.
---
## Phase 3: User Story 1 — Log In and Receive a Token (Priority: P1) 🎯 MVP
**Goal**: The owner can log in with username and password and receive a bearer
token. The Angular SPA has a working login page backed by a real API endpoint.
**Independent Test**: POST `{"username": "owner", "password": "correct"}` to
`/api/v1/auth/token`. Confirm a `200` response with a non-empty `access_token`.
Then open the browser, enter credentials in the login form, and confirm navigation
to the library. Subsequent protected requests in the browser include the token.
### Tests for User Story 1 API (write FIRST — must FAIL before T009) ⚠️
- [X] T008 [US1] Write 5 integration tests in `api/tests/integration/test_auth.py` for the (not-yet-existing) `POST /api/v1/auth/token` endpoint: `test_login_success` (POST valid creds → 200, response has `access_token` as non-empty string, `token_type="bearer"`, `expires_in > 0`), `test_login_wrong_password` (correct username, wrong password → 401, code `invalid_credentials`), `test_login_wrong_username` (wrong username → 401, code `invalid_credentials`), `test_login_missing_password` (body `{"username": "x"}` → 422), `test_login_missing_username` (body `{"password": "x"}` → 422); these tests should use a fixture with the `JWTAuthProvider` override; run `pytest api/tests/integration/test_auth.py` and confirm all 5 **fail** with `404` (route not yet registered)
### Implementation for User Story 1 (API)
- [X] T009 [US1] In `api/app/dependencies.py`, add `get_jwt_auth() -> JWTAuthProvider` — a typed dependency that returns the same `JWTAuthProvider` instance as `get_auth()` but with the concrete type, so the auth router can call `verify_credentials()` and `create_token()` without a downcast (the login endpoint is inherently tied to token issuance and is replaced wholesale in Phase 3, so it is correct for it to depend on the concrete type rather than the `AuthProvider` abstraction). Then create `api/app/routers/auth.py`: define `LoginRequest` Pydantic model (`username: str`, `password: str`), define `TokenResponse` Pydantic model (`access_token: str`, `token_type: str = "bearer"`, `expires_in: int`), add `POST /auth/token` route that injects `auth: JWTAuthProvider = Depends(get_jwt_auth)` — calls `auth.verify_credentials(username, password)`, raises `HTTPException(401, {"detail": "Invalid credentials", "code": "invalid_credentials"})` on failure, calls `auth.create_token()` and returns `TokenResponse` on success; complete T005's `conftest.py` `jwt_auth_provider` fixture import now that the module exists
- [X] T010 [US1] Update `api/app/dependencies.py`: in `get_auth()`, replace `NoOpAuthProvider()` with `JWTAuthProvider(secret_key=s.jwt_secret_key, expiry_seconds=s.jwt_expiry_seconds, owner_username=s.owner_username, owner_password=s.owner_password)` (loading settings via `get_settings()`); the existing `client` fixture in `conftest.py` still overrides `get_auth` with `NoOpAuthProvider`, so all existing tests remain unaffected
- [X] T011 [US1] Update `api/app/main.py`: import `auth` router from `app.routers.auth` and register it with `app.include_router(auth.router, prefix="/api/v1")`; run `pytest api/tests/integration/test_auth.py` and confirm all 5 tests pass; run `pytest api/` and confirm no regressions
### Tests for User Story 1 (Angular — write FIRST — must FAIL before T014) ⚠️
- [X] T012 [P] [US1] Write 3 unit tests in `ui/src/app/auth/auth.service.spec.ts` for the (not-yet-existing) `AuthService`: `test_login_stores_token` (mock `HttpClient` POST returning `{access_token: "tok"}`, verify `sessionStorage.getItem("auth_token") === "tok"` after `login()` completes), `test_isAuthenticated_true_when_token_present` (set token in sessionStorage, assert `isAuthenticated()` returns true), `test_isAuthenticated_false_when_no_token` (clear sessionStorage, assert `isAuthenticated()` returns false); run `ng test` and confirm all 3 **fail** with `Cannot find module` or similar. Note: logout tests belong to US4 and are written in T025.
- [X] T013 [P] [US1] Write 4 unit tests in `ui/src/app/login/login.component.spec.ts` for the (not-yet-existing) `LoginComponent`: `test_submit_calls_auth_service_login` (spy on `AuthService.login`, fill form, submit, verify `login` called with correct username and password), `test_navigates_to_library_on_success` (mock `AuthService.login` returning `of(void 0)`, submit, verify `Router.navigate` called with `['/']`), `test_shows_error_on_401` (mock `AuthService.login` throwing `HttpErrorResponse` with status 401, submit, verify error message element is visible in the template), `test_shows_validation_error_on_empty_fields` (disable browser-native validation via `novalidate`, leave username and password blank, click submit, verify no `HttpClient.post` call was made and a validation error element is visible in the DOM); run `ng test` and confirm all 4 **fail**
### Implementation for User Story 1 (Angular)
- [X] T014 [P] [US1] Create `ui/src/app/auth/auth.service.ts`: `TOKEN_KEY = 'auth_token'`; `login(username: string, password: string): Observable<void>` — POST `/api/v1/auth/token`, pipe `tap(res => sessionStorage.setItem(this.TOKEN_KEY, res.access_token))`, map to void; `logout(): void``sessionStorage.removeItem(this.TOKEN_KEY)`; `getToken(): string | null``sessionStorage.getItem(this.TOKEN_KEY)`; `isAuthenticated(): boolean``this.getToken() !== null`; decorate with `@Injectable({ providedIn: 'root' })`
- [X] T015 [P] [US1] Create `ui/src/app/login/login.component.ts` (standalone component, route `/login`): reactive form with `username` (required) and `password` (required) validators; `onSubmit()` calls `AuthService.login()`, sets `loading = true` while in-flight, on success reads `returnUrl` query param (default `'/'`) and calls `router.navigateByUrl(returnUrl)`, on error sets `errorMessage = 'Invalid username or password'`; template (`login.component.html`) includes a form with username input, password input, submit button (disabled while loading), and an error paragraph (`*ngIf="errorMessage"`)
- [X] T016 [US1] Update `ui/src/app/app.routes.ts`: add `{ path: 'login', loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) }` before the wildcard route; run `ng test` and confirm T012 and T013 tests now pass; run `ng build` to confirm no build errors
**Checkpoint**: `POST /api/v1/auth/token` works end-to-end. Angular login form posts credentials, stores token, navigates to library. All 12 new tests (5 API + 3 Angular service + 4 Angular login) pass.
---
## Phase 4: User Story 2 — Protected Write Actions Require Authentication (Priority: P1)
**Goal**: Upload, delete, and tag-update endpoints reject unauthenticated requests
with a `401`. Angular automatically attaches the stored token to all requests.
**Independent Test**: Without logging in, attempt `POST /api/v1/images` — confirm
`401` with code `unauthorized`. Log in, then upload again — confirm `200/201`. In
the browser, verify that after login the upload form submits successfully.
### Tests for User Story 2 API (write FIRST — must FAIL before T019) ⚠️
- [X] T017 [US2] Add 6 integration tests using the `authed_client` fixture across existing test files: in `api/tests/integration/test_upload.py` add `test_upload_without_token_returns_401` (POST with no `Authorization` header → 401, code `unauthorized`) and `test_upload_with_valid_token_succeeds` (POST with `Authorization: Bearer <token>` → 200/201); in `api/tests/integration/test_delete.py` add `test_delete_without_token_returns_401` (DELETE with no token → 401) and `test_delete_with_valid_token_succeeds` (DELETE with valid token → 204); add `test_patch_tags_without_token_returns_401` (PATCH `/images/{id}/tags` with no token → 401) and `test_patch_tags_with_valid_token_succeeds` (PATCH with valid token → 200) to `api/tests/integration/test_upload.py` (or a new `test_protected.py`); run these 6 tests and confirm they all **fail** (currently return 200/204 without auth, or fixture not yet usable)
### Implementation for User Story 2 (API)
- [X] T018 [US2] Add `require_auth` async dependency to `api/app/dependencies.py`: `async def require_auth(authorization: str | None = Header(None, alias="Authorization"), auth: AuthProvider = Depends(get_auth)) -> Identity` — calls `await auth.get_identity(authorization)`, raises `HTTPException(401, {"detail": "Authentication required", "code": "unauthorized"})` if `identity.anonymous` is True, otherwise returns `Identity`
- [X] T019 [US2] In `api/app/routers/images.py`: add `_: Identity = Depends(require_auth)` parameter to `upload_image()`, `delete_image()`, and `update_image_tags()`; also remove the existing `auth: AuthProvider = Depends(get_auth)` from `upload_image()` (it was injected but never called — `require_auth` now subsumes it); add `from app.dependencies import require_auth` and `from app.auth.provider import Identity` to imports; run `pytest api/tests/integration/` and confirm all 6 new protected-endpoint tests pass and all pre-existing tests (which use the `client` fixture with `NoOpAuthProvider` override) still pass
### Tests for User Story 2 (Angular — write FIRST — must FAIL before T021) ⚠️
- [X] T020 [US2] Write 3 unit tests in `ui/src/app/auth/auth.interceptor.spec.ts` for the (not-yet-existing) `authInterceptor`: `test_adds_auth_header_when_authenticated` (configure `TestBed` with `authInterceptor`, spy `AuthService.getToken()` returning `"test-token"`, make any HTTP request via `HttpClient`, verify the outgoing request in `HttpTestingController` has header `Authorization: Bearer test-token`), `test_no_auth_header_when_not_authenticated` (spy `AuthService.getToken()` returning `null`, make HTTP request, verify `Authorization` header is absent), `test_interceptor_redirects_to_login_on_401` (spy `AuthService.getToken()` returning `"test-token"`, flush the HTTP response with status 401, spy `AuthService.logout()` and `Router.navigate`, verify `logout()` was called and `router.navigate(['/login'])` was called); run `ng test` and confirm all 3 **fail**
### Implementation for User Story 2 (Angular)
- [X] T021 [US2] Create `ui/src/app/auth/auth.interceptor.ts` as a functional interceptor that handles both outbound token injection and inbound 401 responses: `export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); const token = auth.getToken(); if (token) { req = req.clone({ setHeaders: { Authorization: \`Bearer \${token}\` } }); } return next(req).pipe(catchError(err => { if (err instanceof HttpErrorResponse && err.status === 401) { auth.logout(); router.navigate(['/login']); } return throwError(() => err); })); }`; import `catchError`, `throwError` from `rxjs/operators` and `HttpErrorResponse` from `@angular/common/http`
- [X] T022 [US2] Update `ui/src/app/app.config.ts`: change `provideHttpClient()` to `provideHttpClient(withInterceptors([authInterceptor]))`; add the necessary imports; run `ng test` and confirm T020's 3 tests now pass; run `ng build` to confirm no build errors; run `pytest api/` to confirm all API tests still pass
**Checkpoint**: Upload, delete, and tag-update reject unauthenticated requests. The Angular interceptor attaches the token on outbound requests and redirects to `/login` on any 401 response (covering expired mid-session tokens). All 9 new tests (6 API + 3 Angular) pass.
---
## Phase 5: User Story 3 — Public Read Access (Priority: P1)
**Goal**: All read endpoints remain accessible without any credential. Verify no
401 regression was introduced by the changes in US2.
**Independent Test**: Without providing any `Authorization` header, call
`GET /api/v1/images`, `GET /api/v1/images/{id}`, `GET /api/v1/images/{id}/file`,
`GET /api/v1/images/{id}/thumbnail`, and `GET /api/v1/tags`. All must return `200`.
### Tests for User Story 3 (write FIRST — must FAIL before T024) ⚠️
- [X] T023 [US3] Add 5 regression integration tests using the `authed_client` fixture (which uses `JWTAuthProvider` but no `Authorization` header in the request) in a new `api/tests/integration/test_public_access.py`: `test_list_images_without_token_is_200` (GET `/api/v1/images` with no auth header → 200), `test_get_image_without_token_is_200` (upload image first using `authed_client` with token, then GET `/api/v1/images/{id}` with no auth header → 200), `test_serve_file_without_token_is_200` (GET `/api/v1/images/{id}/file` with no auth header → 200), `test_serve_thumbnail_without_token_is_200` (GET `/api/v1/images/{id}/thumbnail` with no auth header → 200), `test_list_tags_without_token_is_200` (GET `/api/v1/tags` with no auth header → 200); run these tests and confirm they all **fail** (they will fail until T019 is complete because the `authed_client` fixture may not yet be fully wired — or they may pass if the fixture is ready, in which case document as already-green)
### Verification for User Story 3
- [X] T024 [US3] Run `pytest api/tests/integration/test_public_access.py` and confirm all 5 pass; run `pytest api/ -v` to confirm the full API suite passes without regressions; document the passing test count
**Checkpoint**: All public read endpoints confirmed accessible without a token. No 401 regression introduced by the protected-write changes.
---
## Phase 6: User Story 4 — Log Out (Priority: P2)
**Goal**: The owner can end their session. After logout, the token is gone from
the browser and the upload page redirects to login.
**Independent Test**: Log in, verify the upload page is accessible. Click the
logout control. Verify the application navigates to `/login`. Navigate directly
to `/upload` — confirm redirect to `/login`.
### Tests for User Story 4 (write FIRST — must FAIL before T027) ⚠️
- [X] T025 [P] [US4] Add 2 unit tests to `ui/src/app/auth/auth.service.spec.ts`: `test_logout_removes_token_from_storage` (set a token in sessionStorage, call `logout()`, confirm `sessionStorage.getItem("auth_token")` is null), `test_isAuthenticated_false_after_logout` (set token, call `logout()`, confirm `isAuthenticated()` returns false); these tests cover logout behaviour which belongs to US4 and was intentionally excluded from T012; `logout()` is implemented in T014 so these tests should pass immediately — confirm they pass before proceeding
- [X] T026 [P] [US4] Write 1 unit test in a new `ui/src/app/auth/auth.guard.spec.ts` for the (not-yet-existing) `authGuard`: `test_redirects_to_login_when_not_authenticated` — configure `TestBed` with `provideRouter([])` and `provideLocationMocks()` (standalone Angular 17+ pattern; do NOT use the deprecated `RouterTestingModule`), spy `AuthService.isAuthenticated()` returning `false`, execute the guard function directly with a mock `ActivatedRouteSnapshot` and `RouterStateSnapshot` with `url = '/upload'`, assert the returned value is a `UrlTree` whose `toString()` starts with `/login`; run `ng test` and confirm it **fails**
### Implementation for User Story 4
- [X] T027 [P] [US4] Create `ui/src/app/auth/auth.guard.ts` as a functional `CanActivateFn`: inject `AuthService` and `Router`; if `auth.isAuthenticated()` return `true`; else return `router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })`
- [X] T028 [P] [US4] Update `ui/src/app/app.routes.ts`: add `canActivate: [authGuard]` to the `/upload` route entry; add import for `authGuard`; add `CanActivateFn` guard to the route object
- [X] T029 [P] [US4] Update `ui/src/app/detail/detail.component.ts`: inject `AuthService` as `public auth: AuthService`; in the template, wrap the tag-edit input block and the delete button with `*ngIf="auth.isAuthenticated()"` so they are hidden for unauthenticated visitors; the image display and read-only tag chips remain visible to all
- [X] T030 [US4] Add a logout link/button to the application shell (`ui/src/app/app.component.ts` and its template): inject `AuthService` and `Router`; add `onLogout()` method that calls `auth.logout()` then `router.navigate(['/login'])`; render the button only when `auth.isAuthenticated()` is true; run `ng test` and confirm T025 and T026 tests pass; run `ng build` to confirm no build errors
**Checkpoint**: Logout works. Upload page is guarded. Detail page hides write controls for unauthenticated visitors. All 3 new Angular tests (2 service + 1 guard) pass.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Environment documentation, final linting, and complete test run.
- [X] T031 [P] Update `.env.example`: add four new variables with comments:
```
# Owner credentials and JWT signing secret
JWT_SECRET_KEY=change-me-to-a-long-random-string
JWT_EXPIRY_SECONDS=86400
OWNER_USERNAME=owner
OWNER_PASSWORD=change-me
```
- [X] T032 [P] Run `~/.local/bin/ruff check api/app/auth/jwt_provider.py api/app/routers/auth.py api/app/dependencies.py api/app/config.py api/app/routers/images.py` and `ruff format --check` on the same files; fix any lint or formatting violations
- [X] T033 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total)
- [X] T034 Run `ng test` inside the UI container (or locally) and confirm all Angular unit tests pass; run `ng build` and confirm the Angular build succeeds with no errors
- [X] T035 End-to-end smoke test: `docker compose up`, open the browser, verify: (a) the library loads without login, (b) navigating to `/upload` redirects to `/login`, (c) logging in navigates to the library, (d) uploading an image succeeds, (e) logging out redirects to `/login`, (f) attempting `/upload` again redirects to `/login`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 complete (PyJWT must be installed before tests can import `JWTAuthProvider`)
- **US1 (Phase 3)**: Depends on Phase 2 complete (`JWTAuthProvider` must exist before login endpoint tests reference it)
- **US2 (Phase 4)**: Depends on Phase 3 complete (Angular interceptor needs `AuthService`; API `require_auth` needs `JWTAuthProvider`)
- **US3 (Phase 5)**: Depends on Phase 4 complete (`require_auth` must be wired before public-access regression tests are meaningful)
- **US4 (Phase 6)**: Depends on Phase 3 complete (`AuthService.logout()` may already be implemented in T014; guard and route changes depend on login route existing from T016)
- **Polish (Phase 7)**: Depends on all feature phases complete
### Within Each Phase
- T006 (write failing tests) MUST precede T007 (implement JWTAuthProvider)
- T008 (write failing API auth tests) MUST precede T009 (implement login route)
- T012 and T013 (write failing Angular tests) MUST precede T014 and T015
- T017 (write failing 401 tests) MUST precede T018 + T019
- T020 (write failing interceptor tests) MUST precede T021
- T023 (write failing public-access tests) MUST precede T024
- T025 and T026 (write failing US4 tests) MUST precede T027T030
### Parallel Opportunities (within phases)
- T003 and T004 can run in parallel (different files in `api/app/auth/`)
- T009, T010, T011 are sequential (dependencies.py → auth.py → main.py)
- T012 and T013 can run in parallel (different spec files)
- T014 and T015 can run in parallel after T012 and T013 (different component files)
- T020 MUST precede T021 (TDD: confirm 3 tests fail before implementing interceptor)
- T025 and T026 can run in parallel (different spec files)
- T027, T028, T029 can run in parallel (different files: guard, routes, detail component)
- T031 and T032 can run in parallel
---
## Parallel Example: Phase 1 (Setup)
```bash
# T003 and T004 touch different files — run together:
Task: "Update AuthProvider interface signature in api/app/auth/provider.py"
Task: "Update NoOpAuthProvider signature in api/app/auth/noop.py"
```
## Parallel Example: Phase 3 / US1 (Angular)
```bash
# T012 and T013 touch different spec files — run together:
Task: "Write 3 failing AuthService unit tests in ui/src/app/auth/auth.service.spec.ts"
Task: "Write 4 failing LoginComponent unit tests in ui/src/app/login/login.component.spec.ts"
# T014 and T015 touch different source files — run together after T012/T013:
Task: "Create AuthService in ui/src/app/auth/auth.service.ts"
Task: "Create LoginComponent in ui/src/app/login/login.component.ts"
```
## Parallel Example: Phase 4 / US2 (Angular)
```bash
# T020 MUST precede T021 (TDD). Within US2 they are sequential.
# T020 can run in parallel with other US2 API tasks (T017, T018, T019 touch different files):
Task: "Write 3 failing interceptor tests in ui/src/app/auth/auth.interceptor.spec.ts" # T020
# (after T020 confirms failing):
Task: "Create authInterceptor in ui/src/app/auth/auth.interceptor.ts" # T021
```
## Parallel Example: Phase 6 / US4
```bash
# T027, T028, T029 touch different files — run together:
Task: "Create authGuard in ui/src/app/auth/auth.guard.ts"
Task: "Add canActivate guard to /upload route in ui/src/app/app.routes.ts"
Task: "Conditionally show write controls in ui/src/app/detail/detail.component.ts"
```
---
## Implementation Strategy
### MVP (User Stories 1 + 2 + 3 — minimum shippable auth)
All three P1 stories are interdependent: login (US1) enables write-protection
(US2), and write-protection must not break public reads (US3). Complete phases
in order:
1. Phase 1: Setup (T001T005)
2. Phase 2: Foundational JWT provider (T006T007)
3. Phase 3: US1 Login API + Angular (T008T016)
4. Phase 4: US2 Protected writes API + Angular interceptor (T017T022)
5. Phase 5: US3 Public-read regression (T023T024)
6. **STOP and VALIDATE**: Login, upload (authenticated), and public browse all work
### Incremental add-on: Logout (US4)
Once MVP is validated, add Phase 6 (T025T030) to complete the session
lifecycle. This is independently addable without revisiting previous phases.
### Total tasks: 35 (T001T035)
---
## Notes
- [P] tasks touch different files and have no mutual dependencies within their phase
- T006, T008, T012, T013, T017, T020, T023, T025, T026 are all "write failing test" steps — always confirm failure before implementing
- The `client` fixture in `conftest.py` uses `NoOpAuthProvider` and MUST NOT be changed — all existing tests depend on it passing without a token
- The `authed_client` fixture returns `(client, valid_token)` — tests choose whether to include the token, enabling both 401 and success scenarios from the same fixture
- The `authInterceptor` attaches the token unconditionally to all requests; the API silently ignores the `Authorization` header on public endpoints — no URL matching needed in the interceptor
- Logout in the UI invalidates the client-side session only; the JWT technically remains valid until its `exp` (acceptable for a single-user local app with no token revocation)

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: UI Polish & Design System
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit-plan`.

242
specs/005-ui-polish/plan.md Normal file
View File

@@ -0,0 +1,242 @@
# Implementation Plan: UI Polish & Design System
**Branch**: `005-ui-polish` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/005-ui-polish/spec.md`
## Summary
Refine the existing Angular SPA from functional-but-bare to intentional and
finished. All changes are purely front-end: a shared design-token layer
(CSS custom properties) is introduced in `styles.css`, and each of the five
views (library, upload, detail, login, app shell) is updated to use those tokens
and to handle loading, empty, and error states consistently. No new dependencies,
no new API endpoints.
---
## Technical Context
**Language/Version**: TypeScript 5 / Angular 19 (standalone components, no NgModules)
**Primary Dependencies**: Angular 19, RxJS 7 (already installed; no new deps added)
**Storage**: N/A — UI-only feature
**Testing**: Karma / Jasmine (Angular CLI default; `npm test`)
**Target Platform**: Browser SPA (desktop-primary, 375 px minimum viewport)
**Project Type**: Web application — UI layer only
**Performance Goals**: Loading indicators must not flash on sub-150 ms responses
**Constraints**: No new npm dependencies; no external icon or component library
**Scale/Scope**: Five component files + one global CSS file
---
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation of concerns — UI knows nothing about storage or DB | ✅ Pass | No API or storage changes |
| §2.2 Dependency direction — UI → API only | ✅ Pass | No new API calls introduced |
| §2.3 Storage abstraction | ✅ Pass | Not touched |
| §2.4 Auth abstraction — identity resolution via AuthProvider | ✅ Pass | Auth logic unchanged; FR-006 (hide write controls) already implemented |
| §2.6 No speculative abstraction | ✅ Pass | Tokens centralised because all five views use them; no hypothetical interfaces |
| §3.3 Error shape | ✅ Pass | UI consumes existing error envelopes; no API change |
| §5.1 TDD non-negotiable | ✅ Pass | All template and state changes will have Angular component tests written first |
| §5.2 Test pyramid | ✅ Pass | Unit tests (Karma) cover state logic; E2E visual check is the acceptance gate |
| §6 Tech stack | ✅ Pass | Angular + TypeScript; no new languages or frameworks added |
| §7.2 No hardcoded values | ✅ Pass | Colours/spacing moved to CSS custom properties, not hardcoded further |
| §7.3 Linting non-optional | ✅ Pass | ESLint + Prettier enforced; `ng build` type-check must pass |
| §8 Scope boundaries | ✅ Pass | UI-only; no multi-user, no OR/NOT tags, no OIDC |
**Constitution Check result: ALL GATES PASS**
No violations. No complexity justification table required.
---
## Project Structure
### Documentation (this feature)
```text
specs/005-ui-polish/
├── plan.md ← this file
├── research.md ← Phase 0 output (complete)
├── quickstart.md ← Phase 1 output (visual acceptance scenarios)
└── tasks.md ← Phase 2 output (/speckit-tasks — not yet created)
```
*No `data-model.md` or `contracts/` — this feature introduces no new data
entities and no API surface changes.*
### Source Code (affected files)
```text
ui/
└── src/
├── styles.css ← Add CSS custom properties (design tokens)
└── app/
├── app.component.ts ← Polish header shell
├── library/
│ └── library.component.ts ← Skeleton load, empty state, error state, card polish
├── upload/
│ └── upload.component.ts ← Drop-zone polish, in-progress state, success/error states
├── detail/
│ └── detail.component.ts ← Loading state, not-found state, section organisation
└── login/
└── login.component.ts ← Visual alignment with design system
```
---
## Milestones
### Milestone 1 — Design Token Layer (blocks all other milestones)
Extract the shared colour, spacing, and motion values already present across the
five components into CSS custom properties on `:root` in `styles.css`.
**Deliverable**: `:root` block in `styles.css` with 13 named tokens (see
research.md Decision 5). Each existing component still renders identically
(tokens match current hard-coded values exactly). `ng build` passes.
**Token set**:
```
--bg, --surface, --surface-raised, --border, --border-focus,
--text, --text-muted, --accent, --accent-text, --danger, --danger-text,
--radius, --radius-chip, --transition
```
---
### Milestone 2 — Library View (US1)
**Loading state**: Replace the current `loading = true` boolean with the
150 msdebounced spinner pattern (see research.md Decision 3). While loading,
render a grid of skeleton cards (same dimensions as real cards) using the
shimmer CSS class (see research.md Decision 2).
**Empty state**: The existing empty-state `<p>` is already functional. Polish it:
centred layout, muted icon (✦ or similar Unicode), larger text, and a prominent
"Upload your first image" link that navigates to `/upload`.
**Error state**: Add an `error: boolean` flag to the component. If the `list()`
call errors, set `error = true` and render an error card with a retry button
that calls `load()` again.
**Card polish**: Apply tokens to card background, border-radius, and tag chips.
Add a subtle `box-shadow` and `transform: translateY(-2px)` on hover (using
`--transition`). Ensure the card thumbnail `<img>` has an `(error)` fallback
(see research.md Decision 4).
**Responsive**: The existing `auto-fill minmax(200px, 1fr)` grid already handles
narrow viewports. Verify it does not overflow at 375 px; reduce min card width
to 160 px if needed.
---
### Milestone 3 — Upload View (US2)
**Drop-zone polish**: Apply token-based border and background to the existing
drag-and-drop zone. Add a dashed border accent colour (`--accent` at 40%
opacity) on active drag state.
**In-progress state**: The existing `loading` flag already disables the button.
Add a visible spinner or animated label ("Uploading…") inside the button while
in-flight so the state change is unmistakable.
**Success state**: After a successful upload, show a brief success banner
(green-tinted surface, tick character) with a "Upload another" link and a "View
in library" link. Auto-dismiss after 4 seconds or on navigation.
**Error states**: Distinct messages for validation errors (wrong type/size —
already returned by API) vs. network/server errors (generic retry). Both
displayed inline below the form, not in a modal.
**Double-submit prevention**: Already implemented (button disabled while
`loading`). Confirm the disabled style is visually clear using `--text-muted`
and reduced opacity.
---
### Milestone 4 — Detail View (US3)
**Loading state**: Add a skeleton layout while `loading = true`: a grey
rectangle at full width for the image area, and two skeleton chip rows below.
**Not-found state**: The existing `!image && !loading` condition renders a
plain text paragraph. Replace with a styled not-found card: centred layout,
muted icon, "Image not found" heading, and a "Back to library" button.
**Section organisation**: Visually separate the image area, tags section, and
write controls with consistent spacing using `--surface` panels and token-based
gaps. Write controls (tag input + delete button) should be grouped in a visually
distinct "Owner actions" area when visible.
**Tag error**: The existing `tagError` renders inline. Apply `--danger` colour
and a left border accent to make it unmistakable.
**Broken image**: Add `(error)` handler on the full-size `<img>` in the detail
view (inline SVG placeholder showing a broken-link icon).
---
### Milestone 5 — Login View (US4)
Apply the token-based design system to the login form:
- Centre the card vertically and horizontally on the page
- Wrap the form in a `--surface` card with `--radius` and a subtle border
- Use token-based input styles matching the library filter bar
- Display field-level validation errors using `--danger` colour
- The submit button uses the same `--accent` style as the library upload button
- In-progress state: button text changes to "Signing in…", button disabled
No layout changes to the existing reactive-form structure.
---
### Milestone 6 — App Shell (US5)
The existing `app.component.ts` header already conditionally renders the
sign-out button. Polish:
- Slim top bar: `--surface` background, bottom border using `--border`
- App name / logo mark on the left (text only, no image asset)
- Sign-out button aligned right using `--text-muted` colour and a simple
hover state
- Header height: `48px` fixed; does not reflow page content on state change
---
## Implementation Order
```
Milestone 1 (tokens)
Milestone 2 (library) ─┐
Milestone 3 (upload) ├─ can proceed in parallel after M1
Milestone 4 (detail) │
Milestone 5 (login) ─┘
Milestone 6 (shell) ← last (touches app.component which wraps all views)
```
M2M5 are independent of each other (different component files). M6 is last
because the app shell wraps all views and its final state is easiest to validate
once the inner views are stable.
---
## Testing Strategy
**Unit tests (Karma/Jasmine)**:
- All new state variables (`error`, `showSpinner`, skeleton visibility) are
tested via component spec files.
- Template conditionals (`*ngIf="error"`, `*ngIf="loading"`) are verified with
fixture queries.
- The `(error)` image fallback handler is tested by simulating an error event.
- Existing tests must continue to pass — no regressions.
**Visual acceptance (manual, quickstart.md)**:
- Each milestone has a corresponding scenario in quickstart.md.
- Visual checks are performed in a running `docker compose up` stack.
- 375 px viewport check: Chrome DevTools → device toolbar → iPhone SE.
**Build gate**: `ng build` must pass with zero errors after every milestone.

View File

@@ -0,0 +1,155 @@
# Quickstart: UI Polish Visual Acceptance Scenarios
Use this guide to manually verify each milestone after implementation.
Run `docker compose up` before starting. Open the browser at `http://localhost:4200`.
---
## M1 — Design Token Layer
**Goal**: Tokens exist; visual output is identical to before.
1. Open the library, upload, detail, and login pages.
2. Open browser DevTools → Elements → `<html>` or `<body>`.
3. Confirm `--bg`, `--surface`, `--accent`, `--danger` etc. are visible in
computed styles.
4. Confirm no visible change in any view compared to before M1.
---
## M2 — Library View
### Loading skeleton
1. Open DevTools → Network → set throttle to "Slow 3G".
2. Hard-refresh the library page.
3. **Expect**: A grid of grey shimmer cards appears immediately; no blank white
space; no layout jump when real images load in.
### Empty state
1. Ensure no images are uploaded (or use a fresh test database).
2. Open the library.
3. **Expect**: A centred empty-state panel with explanatory text and a prominent
"Upload your first image" link. Clicking the link navigates to `/upload`.
### Error state
1. Stop the API container (`docker compose stop api`).
2. Hard-refresh the library.
3. **Expect**: An error card with a plain-language message and a "Retry" button.
No blank grid, no raw status code.
4. Restart the API (`docker compose start api`) and click "Retry".
5. **Expect**: Images load successfully.
### Card polish
1. Hover over an image card.
2. **Expect**: Card lifts slightly (2 px translate) with a smooth transition.
### Broken image
1. Manually corrupt a storage key in the database (or unplug MinIO) and reload.
2. **Expect**: Card shows a grey placeholder graphic, not a broken-image browser icon.
### 375 px viewport
1. DevTools → Device toolbar → iPhone SE (375 × 667).
2. **Expect**: Cards stack, no horizontal scrollbar, all content readable.
---
## M3 — Upload View
### Drop-zone idle
1. Navigate to `/upload` (must be logged in).
2. **Expect**: A visually distinct drop-zone with dashed border and clear
instructions. Submit button is disabled/greyed.
### In-progress
1. Select a large image file.
2. Click upload.
3. **Expect**: Button label changes to "Uploading…" and is disabled. A spinner
or animated indicator is visible.
### Success
1. After a successful upload completes.
2. **Expect**: A green-tinted success banner with the filename, "Upload another"
link, and "View in library" link. Banner disappears after 4 seconds.
### Validation error
1. Attempt to upload a `.txt` file.
2. **Expect**: An inline error message names the problem ("Unsupported file type").
The form is still usable — no page reload required.
### Network error
1. Stop the API mid-upload (or use DevTools → block the upload request).
2. **Expect**: A generic inline error with guidance to retry. Form remains usable.
---
## M4 — Detail View
### Loading skeleton
1. Set network throttle to Slow 3G.
2. Navigate directly to an image URL (e.g., `http://localhost:4200/images/<id>`).
3. **Expect**: A grey rectangle skeleton for the image area and chip skeletons
below. No blank page.
### Not-found state
1. Navigate to `http://localhost:4200/images/00000000-0000-0000-0000-000000000000`.
2. **Expect**: A styled not-found card with "Image not found" heading and a
"Back to library" button. No blank page, no raw 404 text.
### Authenticated write controls
1. Log in and open a detail page.
2. **Expect**: Tag editing input and delete button are visible and clearly grouped.
### Unauthenticated view
1. Open the detail page in a private/incognito window (not logged in).
2. **Expect**: Image and read-only tag chips are visible. No tag input, no
delete button.
### Tag error
1. While logged in, attempt to add a tag with invalid characters (e.g., `TAG!`).
2. **Expect**: An inline error in danger colour with a left accent border.
Other tags and the image remain visible.
### Broken image
1. Open a detail page for an image whose storage object has been deleted.
2. **Expect**: A placeholder graphic replaces the image. Page layout is not broken.
---
## M5 — Login View
### Visual consistency
1. Open `/login` without being logged in.
2. **Expect**: Dark background matching the library; form centred in a surface
card; same font and spacing as other pages.
### Field validation
1. Click the Sign In button without entering any credentials.
2. **Expect**: Inline validation messages appear on the username and password
fields without a page reload.
### Invalid credentials error
1. Enter wrong credentials and submit.
2. **Expect**: A single error message below the form. Fields retain their values.
### In-progress state
1. Submit valid credentials (throttle network if needed to see the state).
2. **Expect**: Button label changes to "Signing in…" and is disabled while the
request is in flight.
---
## M6 — App Shell
### Authenticated header
1. Log in and navigate between library, upload, and an image detail page.
2. **Expect**: A consistent 48 px header is present on all pages. Sign-out
control is visible on the right. Header does not reflow content.
### Unauthenticated header
1. Open the library or detail page without logging in.
2. **Expect**: Header is present but sign-out control is absent.
### Sign out
1. Click the sign-out control in the header.
2. **Expect**: Redirected to `/login`. Header no longer shows sign-out control.
Navigating to `/upload` redirects back to `/login`.

View File

@@ -0,0 +1,137 @@
# Research: UI Polish & Design System
## Decision 1: Design token delivery mechanism
**Decision**: CSS custom properties declared on `:root` in `styles.css`.
**Rationale**: Angular's default ViewEncapsulation.Emulated scopes component
selectors with attribute hashes but does not block CSS custom property
inheritance. `:root` variables cascade into every inline component style block
without any special configuration. This is now the standard Angular 2025+ token
approach and requires zero new dependencies.
**Alternatives considered**:
- SCSS variables — require a preprocessor; project currently uses plain CSS.
- A shared `.css` import per component — works but adds per-component boilerplate
and duplicates the token surface unnecessarily.
---
## Decision 2: Skeleton loading pattern
**Decision**: Pure-CSS shimmer using `::after` pseudo-element animated gradient;
no third-party library.
**Rationale**: The spec assumption explicitly prohibits new icon/component
libraries. The standard pure-CSS skeleton pattern (placeholder divs with a
light-to-dark horizontal gradient sweeping via `@keyframes`) produces the same
visual result as library skeletons with zero added dependencies. The `::after`
approach requires no extra DOM nodes per skeleton block.
**Pattern**:
```css
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: var(--radius);
}
```
**Alternatives considered**:
- `@angular/material` skeleton — adds the entire Material library as a dep.
- CSS opacity pulse — simpler but less visually informative than a shimmer.
---
## Decision 3: Loading-flash debounce
**Decision**: `timer(150).pipe(takeUntil(response$))` to gate the visibility of
loading indicators.
**Rationale**: Showing a spinner immediately causes a visible flash when the
server responds in under ~150 ms (common on localhost). The idiomatic RxJS
approach is to start a 150 ms timer alongside the real request; if the request
completes first (`takeUntil`), the timer never fires and the spinner never
appears. This avoids `race()` complexity and cleanly unsubscribes.
**Implementation sketch**:
```typescript
showSpinner = false;
load(): void {
const req$ = this.service.fetch().pipe(share());
timer(150).pipe(takeUntil(req$)).subscribe(() => { this.showSpinner = true; });
req$.subscribe(data => { this.showSpinner = false; /* handle data */ });
}
```
**Alternatives considered**:
- `race([req$, timer(150)])` — fires timer regardless of req$ speed on certain
race conditions; harder to reason about.
- CSS `animation-delay` — cannot easily tie delay to actual response time.
---
## Decision 4: Broken-image fallback
**Decision**: Inline `(error)` event binding on `<img>` elements, guarded
against recursive fallback.
**Rationale**: For a small number of distinct image elements (card thumbnail,
detail full-image), an event binding is the minimal idiomatic pattern and
avoids the complexity of a directive. Recursive fallback is prevented by
checking that the current `src` is not already the placeholder before
reassigning.
**Pattern**:
```html
<img [src]="url" (error)="onImgError($event)" />
```
```typescript
onImgError(e: Event): void {
const img = e.target as HTMLImageElement;
if (!img.src.endsWith('placeholder')) {
img.src = 'data:image/svg+xml,...'; // inline SVG placeholder
}
}
```
**Alternatives considered**:
- Custom `ImageFallbackDirective` — reusable but over-engineered for two call
sites; can be extracted later if the pattern spreads.
---
## Decision 5: Global design token set
**Decision**: Seven semantic tokens defined on `:root` in `styles.css`, derived
from colours already present in the components.
| Token | Value | Meaning |
|--------------------|-----------|----------------------------------------------|
| `--bg` | `#0f0f0f` | Page background (already in body) |
| `--surface` | `#1a1a1a` | Card / input / panel background |
| `--surface-raised` | `#252525` | Hover state, skeleton highlight |
| `--border` | `#333` | Subtle dividers, input borders |
| `--border-focus` | `#555` | Input focus ring |
| `--text` | `#e0e0e0` | Primary text (already on body) |
| `--text-muted` | `#777` | Secondary text, placeholders |
| `--accent` | `#4a9eff` | CTAs, active chips (already in upload-btn) |
| `--accent-text` | `#000` | Text on accent backgrounds |
| `--danger` | `#c0392b` | Destructive actions (already in delete-btn) |
| `--danger-text` | `#fff` | Text on danger backgrounds |
| `--radius` | `6px` | Standard border radius |
| `--radius-chip` | `12px` | Pill-shaped chips |
| `--transition` | `200ms ease` | Standard animation duration |
**Rationale**: All values are already present in the components but hard-coded
per file. Centralising them eliminates drift without introducing a new colour
palette — the visual result is identical to the current state, but consistent.
**Alternatives considered**:
- Introducing a new darker/lighter palette — unnecessary scope creep; the
existing colours are well-chosen for a dark personal tool.

180
specs/005-ui-polish/spec.md Normal file
View File

@@ -0,0 +1,180 @@
# Feature Specification: UI Polish & Design System
**Feature Branch**: `005-ui-polish`
**Created**: 2026-05-03
**Status**: Draft
**Input**: User description: "Polish the Angular UI with a cohesive visual design. The three main views — library (image grid), upload form, and image detail — should feel intentional and finished. Add proper loading states, empty states, and error states to each view. The overall aesthetic should be dark-themed and minimal, fitting a personal tool used frequently. The login page should also match the design system."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Library Feels Complete (Priority: P1)
The owner opens the app and is greeted by a polished image grid. While images
load, something visually coherent fills the space so the page doesn't feel
broken. If the library is empty, a helpful prompt explains what to do. If the
request fails, a clear error message appears with a way to retry.
**Why this priority**: The library is the landing page and the most-visited
view. Its quality sets first impressions for every session.
**Independent Test**: Open the app with no images uploaded — confirm an
intentional empty state is shown. Upload an image, return to the library —
confirm the grid renders cleanly with consistent card sizing. Throttle the
network — confirm a loading indicator appears before images arrive.
**Acceptance Scenarios**:
1. **Given** the library is loading, **When** the page first renders, **Then** a skeleton or spinner occupies the grid area so the layout does not jump or appear blank.
2. **Given** no images have been uploaded, **When** the library loads successfully, **Then** an empty-state message is shown explaining that no images exist yet, with a visible prompt to upload the first image.
3. **Given** the image fetch fails (network error), **When** the library loads, **Then** an error message is shown with a retry action; the page does not display a blank grid or a raw error code.
4. **Given** images exist, **When** the library renders, **Then** all image cards have consistent size, spacing, and visual weight; tag chips are readable and do not overflow their cards.
---
### User Story 2 — Upload Form Feels Finished (Priority: P1)
The owner navigates to the upload page and finds a form that clearly communicates
its state at every step: idle with a helpful drop-zone, active while uploading
with visible progress, and resolved with success or a plain-language error.
**Why this priority**: Upload is the primary write action. A rough upload
experience erodes confidence in the whole tool.
**Independent Test**: Upload a valid image and confirm the flow from drop-zone
through in-progress indicator to success result is smooth and clearly
communicated. Attempt an upload with an invalid file type and confirm a
plain-language validation error appears without a page reload.
**Acceptance Scenarios**:
1. **Given** the upload page is idle, **When** no file is selected, **Then** a drop-zone with clear instructions is visible; the submit button is visibly disabled.
2. **Given** a file is selected and uploading, **When** the upload is in progress, **Then** the submit button is disabled and a visible in-progress indicator is shown; the user cannot accidentally submit twice.
3. **Given** an upload succeeds, **When** the server responds, **Then** a success confirmation is shown and the owner can navigate onward without confusion.
4. **Given** an upload fails due to an invalid file type or size, **When** the server responds, **Then** a plain-language error message is shown identifying the problem; the form remains usable for another attempt.
5. **Given** an upload fails due to a network or server error, **When** the server responds, **Then** a generic error message is shown with guidance to retry.
---
### User Story 3 — Detail Page Is Well Organised (Priority: P1)
The owner opens an image's detail page and finds the image prominently displayed,
tag management clearly grouped, and write controls (edit tags, delete) visually
distinct from read content. Visitors who are not logged in see the image and
tags but no write controls. Loading and error states are handled gracefully.
**Why this priority**: The detail page is where tag curation and deletion
happen — the two most common editing actions after upload.
**Independent Test**: Open a detail page while logged in — confirm write
controls are visible and clearly grouped. Open the same page while logged out —
confirm write controls are hidden. Navigate to a non-existent image ID — confirm
a not-found state is shown rather than a blank or broken page.
**Acceptance Scenarios**:
1. **Given** the detail page is loading, **When** the route is first entered, **Then** a loading indicator is shown in place of the image and metadata.
2. **Given** the image exists and the owner is logged in, **When** the page renders, **Then** the image is the focal point; tags are displayed below; tag editing and delete controls are clearly grouped and visually differentiated from read content.
3. **Given** the image exists and the visitor is not logged in, **When** the page renders, **Then** the image and tags are visible; no tag-edit input or delete button is present.
4. **Given** a non-existent image ID is requested, **When** the page loads, **Then** a not-found state is shown with a link back to the library; no raw error code or blank area is displayed.
5. **Given** a tag update fails, **When** the owner submits a tag change, **Then** an inline error message explains the failure; the image and other tags remain visible.
---
### User Story 4 — Login Page Matches the Design (Priority: P2)
The owner lands on the login page (directly or after a redirect) and finds a
form that visually belongs to the same application as the library and detail
page. The form clearly communicates validation errors and submission state.
**Why this priority**: Login is visited infrequently. A consistent visual
treatment matters, but functional correctness (already implemented) is more
critical than aesthetic alignment.
**Independent Test**: Navigate to `/login` directly — confirm the page uses the
same colour scheme, typography, and spacing as the rest of the app. Submit with
empty fields — confirm visible validation errors appear without a page reload.
**Acceptance Scenarios**:
1. **Given** the login page loads, **When** the owner views it, **Then** the page uses the same dark background, colour palette, and typographic scale as all other views.
2. **Given** the owner submits with empty username or password, **When** the form is submitted, **Then** inline validation messages appear on the relevant fields without a page reload or server round-trip.
3. **Given** the owner submits invalid credentials, **When** the server rejects them, **Then** a single error message is shown below the form; the fields are not cleared.
4. **Given** the form is submitting, **When** the request is in-flight, **Then** the submit button is disabled and shows an in-progress label so the owner cannot submit twice.
---
### User Story 5 — App Shell Is Consistent (Priority: P2)
Every page shares a consistent outer frame: a slim header that shows the
sign-out control when logged in. The header does not compete with page content
for visual attention but is always present and usable.
**Why this priority**: A coherent shell ties the individual views together into
a single application rather than a collection of pages.
**Independent Test**: Navigate between library, detail, and upload while logged
in — confirm the header is consistent across all views. Sign out and visit a
public page — confirm the sign-out control is absent.
**Acceptance Scenarios**:
1. **Given** the owner is logged in, **When** viewing any page, **Then** a slim header is present with a sign-out control; it does not draw excessive visual attention away from the page content.
2. **Given** the visitor is not logged in, **When** viewing the library or a detail page, **Then** the header is present but contains no sign-out control.
3. **Given** the owner clicks sign out in the header, **When** the action completes, **Then** they are redirected to the login page and the header no longer shows the sign-out control.
---
### Edge Cases
- What happens if an image fails to load (broken URL or storage outage)? The card or detail view should show a placeholder, not the browser's default broken-image icon.
- What happens on a very narrow viewport (mobile browser)? Cards should stack or resize; the layout must not overflow horizontally.
- What happens if a tag is very long? Tag chips must truncate or wrap without breaking the card or detail layout.
- What happens during slow network conditions? Loading states must appear promptly and not flash on fast connections.
---
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: Every view (library, upload, detail, login) MUST display a loading indicator while async data or actions are in progress.
- **FR-002**: The library MUST display a meaningful empty-state message with a call to action when no images exist.
- **FR-003**: All four views MUST display plain-language error messages when an operation fails; raw HTTP status codes or stack traces MUST NOT be shown to the user.
- **FR-004**: The upload form MUST disable the submit control while an upload is in progress to prevent duplicate submissions.
- **FR-005**: The detail page MUST show a not-found state (with a back link) when the requested image does not exist.
- **FR-006**: Write controls on the detail page (tag editing, delete) MUST be hidden for unauthenticated visitors and visible only to the logged-in owner.
- **FR-007**: All views MUST share a consistent set of visual tokens: background colours, text colours, spacing scale, border radii, and interactive-element styles.
- **FR-008**: The application MUST be usable on viewports as narrow as 375 px (iPhone SE width) without horizontal overflow.
- **FR-009**: Loading indicators MUST NOT flash on connections fast enough to resolve in under 150 ms; debounced or skeleton-based approaches are preferred.
- **FR-010**: Broken image assets (failed loads) MUST display a visible placeholder rather than the browser's default broken-image icon.
### Key Entities
- **Design token set**: The shared palette, spacing scale, and typographic rules that all views derive from (background, surface, border, text-primary, text-muted, accent, danger).
- **Loading state**: A visual treatment applied to any view or element while data is being fetched or an action is in progress.
- **Empty state**: A purposeful layout shown when a collection has zero items, including explanatory text and a next-action prompt.
- **Error state**: A purposeful layout shown when an operation fails, including a plain-language description and (where applicable) a retry action.
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Every view transitions from loading to content (or error/empty) without a layout shift visible to the naked eye.
- **SC-002**: All five views pass a visual consistency check: an observer can identify them as belonging to the same application by colour, typography, and spacing alone.
- **SC-003**: The library, upload, and detail views each render without horizontal scrollbars on a 375 px wide viewport.
- **SC-004**: Each error condition (network failure, validation failure, not-found) produces a user-visible message within the current view — zero conditions result in a silent failure or blank screen.
- **SC-005**: Loading indicators do not appear on responses that complete in under 150 ms in a local development environment (no flicker on fast connections).
---
## Assumptions
- The existing dark colour palette already in the components (#1a1a1a backgrounds, #e0e0e0 text, #4a9eff accent) is the correct base; the polish work refines and extends it rather than replacing it wholesale.
- No external component library or icon set is introduced; any icons needed are either inline SVG or Unicode characters to avoid new dependencies.
- The app remains a single-page application; no server-side rendering or route-level transitions are in scope.
- Mobile layout is "good enough to use" at 375 px rather than a fully optimised mobile-first redesign; a dedicated mobile redesign is out of scope.
- No new API endpoints are needed; all changes are purely front-end.
- Animations and transitions are minimal — a single standard duration applied consistently; no complex motion design.
- FR-006 (hiding write controls for unauthenticated visitors) is already implemented in the detail component; this spec confirms the behaviour is preserved and visually correct, not that it needs to be built from scratch.

View File

@@ -0,0 +1,242 @@
# Tasks: UI Polish & Design System
**Input**: Design documents from `specs/005-ui-polish/`
**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, quickstart.md ✓
**Tests**: Component spec tests are included per §5.1 (TDD non-negotiable). Tests are written first and must fail before implementation begins. Karma/Jasmine via Angular CLI test runner.
**Organization**: Phase 2 (design token layer) blocks all user story phases. User story phases (Phase 37) are independent of each other and can proceed in parallel after Phase 2 completes.
**Milestone mapping** (cross-reference with `plan.md` and `quickstart.md`):
Phase 2 = M1 (Tokens) | Phase 3 = M2 (Library) | Phase 4 = M3 (Upload) | Phase 5 = M4 (Detail) | Phase 6 = M5 (Login) | Phase 7 = M6 (Shell)
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1US5)
- All component files are under `ui/src/app/`
---
## Phase 1: Setup
**Purpose**: Verify baseline state before any changes are made.
- [X] T001 Confirm `ng build` passes with zero errors in `ui/` (baseline gate before any changes)
---
## Phase 2: Foundational — M1: Design Token Layer
**Purpose**: Establish the shared CSS custom property layer in `ui/src/styles.css`. This is the blocking prerequisite for all five user story phases — no component work begins until these tokens exist.
**⚠️ CRITICAL**: No user story phase can begin until T004 passes.
- [X] T002 Add 13 CSS custom property tokens to `:root` in `ui/src/styles.css`: `--bg`, `--surface`, `--surface-raised`, `--border`, `--border-focus`, `--text`, `--text-muted`, `--accent`, `--accent-text`, `--danger`, `--danger-text`, `--radius`, `--radius-chip`, `--transition` — use exact values from research.md Decision 5
- [X] T003 Add `@keyframes shimmer` animation and `.skeleton` utility class to `ui/src/styles.css` using the gradient pattern from research.md Decision 2
- [X] T004 Confirm `ng build` passes with zero errors after token additions (M1 gate — components unchanged, visual output identical)
**Checkpoint**: Design token layer complete. User story phases 37 may now start.
---
## Phase 3: User Story 1 — Library Feels Complete (Priority: P1) 🎯 MVP
**Goal**: The library view has skeleton loading, a styled empty state, an error state with retry, polished cards with hover lift, image error fallback, and responsive layout at 375 px.
**Independent Test**: Throttle network to Slow 3G and hard-refresh `/` — confirm shimmer skeleton appears. Remove all images — confirm styled empty state with "Upload your first image" link. Stop API — confirm error card with Retry button. Hover a card — confirm 2 px lift. Set viewport to 375 px — confirm no horizontal scrollbar.
### Tests for User Story 1 (TDD — write first, confirm failure before T008) ⚠️
- [X] T005 [US1] Add component tests for `showSpinner` debounce flag, `error` flag, skeleton card count, empty-state link, error card retry button, and `onImgError` handler in `ui/src/app/library/library.component.spec.ts`
### Implementation for User Story 1
- [X] T006 [P] [US1] Replace all hardcoded colour and spacing values with CSS token variables in the component styles block of `ui/src/app/library/library.component.ts`
- [X] T007 [US1] Replace `loading = true` boolean with `showSpinner = false` and add 150 ms debounce using `timer(150).pipe(takeUntil(req$))` from research.md Decision 3 in `ui/src/app/library/library.component.ts`
- [X] T008 [US1] Add skeleton loading grid: while `showSpinner` is true render 8 `<div class="skeleton card-skeleton">` placeholders at the same dimensions as real cards in `ui/src/app/library/library.component.ts`
- [X] T009 [US1] Add `error = false` flag; set it on `list()` failure; render an error card with plain-language message and "Retry" button that calls `load()` in `ui/src/app/library/library.component.ts`
- [X] T010 [US1] Replace the plain empty-state `<p>` with a centred panel: Unicode icon (✦), larger muted heading, and a `routerLink="/upload"` "Upload your first image" link in `ui/src/app/library/library.component.ts`
- [X] T011 [US1] Add card hover effect: `transform: translateY(-2px)` and `box-shadow` with `transition: var(--transition)` using `--surface-raised` in `ui/src/app/library/library.component.ts`
- [X] T012 [US1] Add `(error)="onImgError($event)"` on the card thumbnail `<img>` and implement `onImgError` with an inline SVG placeholder (guard against recursive fallback per research.md Decision 4) in `ui/src/app/library/library.component.ts`
- [X] T013 [US1] Check the `auto-fill minmax()` value in the grid at 375 px: if cards overflow horizontally, reduce min card width to `160px` and record the change; if no overflow, document "verified at 160px — no change needed" in a code comment in `ui/src/app/library/library.component.ts`
**Checkpoint**: Library view is fully functional with all loading/empty/error/responsive states.
---
## Phase 4: User Story 2 — Upload Form Feels Finished (Priority: P1)
**Goal**: The upload form has a visually distinct drop-zone, visible in-progress state ("Uploading…" + spinner), a success banner with auto-dismiss, distinct validation vs. network error messages, and a clearly disabled button style.
**Independent Test**: Navigate to `/upload` — confirm dashed drop-zone border. Select a large file and click Upload — confirm button shows "Uploading…" and is disabled. After success — confirm green banner appears then disappears after 4 s. Attempt to upload a `.txt` file — confirm "Unsupported file type" inline error.
### Tests for User Story 2 (TDD — write first, confirm failure before T016) ⚠️
- [X] T014 [US2] Add component tests for `loading` button-disabled state, "Uploading…" label, `showSuccess` banner visibility, auto-dismiss timer, validation error message, and network error message in `ui/src/app/upload/upload.component.spec.ts`
### Implementation for User Story 2
- [X] T015 [P] [US2] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/upload/upload.component.ts`
- [X] T016 [US2] Style the drop-zone with a dashed `--accent`-coloured border at 40% opacity; add an active drag state that brightens the border to full `--accent` in `ui/src/app/upload/upload.component.ts`
- [X] T017 [US2] Change submit button label to "Uploading…" and add a CSS spinner `<span>` inside the button while `loading = true` in `ui/src/app/upload/upload.component.ts`
- [X] T018 [US2] Add `showSuccess = false` and `uploadedFilename = ''`; after a successful upload set both, show a green-tinted banner with filename, "Upload another" link, and "View in library" routerLink, then auto-dismiss after 4 s using `setTimeout` in `ui/src/app/upload/upload.component.ts`
- [X] T019 [US2] Show distinct inline error messages: validation errors (wrong type/size from API) show the specific problem; network/server errors show a generic retry message — both rendered below the form without a page reload in `ui/src/app/upload/upload.component.ts`
- [X] T020 [US2] Apply `--text-muted` colour and `opacity: 0.5` to the disabled button style to make the disabled state visually unmistakable in `ui/src/app/upload/upload.component.ts`
**Checkpoint**: Upload form communicates every state clearly and prevents duplicate submission.
---
## Phase 5: User Story 3 — Detail Page Is Well Organised (Priority: P1)
**Goal**: The detail view has a loading skeleton, a network error state with retry, a styled not-found card with back link, a grouped "Owner actions" panel for write controls, danger-styled tag errors, and a broken-image fallback.
**Independent Test**: Throttle to Slow 3G and navigate to `/images/<id>` — confirm skeleton appears. Stop the API and hard-refresh a detail page — confirm error card with retry (not a blank page). Navigate to `/images/00000000-0000-0000-0000-000000000000` — confirm not-found card with "Back to library" button. Log in and open a detail page — confirm write controls are grouped. Open detail page while logged out — confirm write controls absent. Add tag with `!` character — confirm danger-coloured inline error.
### Tests for User Story 3 (TDD — write first, confirm failure before T023) ⚠️
- [X] T021 [US3] Add component tests for skeleton visibility while `loading=true`, network error card when fetch fails (non-404), not-found card when `!image && !loading && !error`, tag error `--danger` style application, and `onImgError` handler in `ui/src/app/detail/detail.component.spec.ts`
### Implementation for User Story 3
- [X] T022 [P] [US3] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/detail/detail.component.ts`
- [X] T023 [US3] Add skeleton loading layout while `loading = true`: a full-width grey `.skeleton` rectangle for the image area and two rows of `.skeleton` chip placeholders below in `ui/src/app/detail/detail.component.ts`
- [X] T024 [US3] Add `error = false` flag; set it on API fetch failure (non-404 errors); render an error card with plain-language "Failed to load image" message, "Back to library" link, and a "Retry" button that calls the fetch again — distinct from the not-found state in `ui/src/app/detail/detail.component.ts`
- [X] T025 [US3] Replace the plain `!image && !loading && !error` paragraph with a styled not-found card: centred layout, muted Unicode icon, "Image not found" `<h2>`, and a "Back to library" `routerLink="/"` button in `ui/src/app/detail/detail.component.ts`
- [X] T026 [US3] Wrap the tag-edit input and delete button in a visually distinct "Owner actions" `<section>` with a `--surface` panel, `--border` top separator, and token-based padding/gap in `ui/src/app/detail/detail.component.ts`
- [X] T027 [US3] Apply `color: var(--danger)` and `border-left: 3px solid var(--danger)` with left-padding to the `tagError` inline error element in `ui/src/app/detail/detail.component.ts`
- [X] T028 [US3] Add `(error)="onImgError($event)"` on the full-size `<img>` and implement `onImgError` with an inline SVG broken-link placeholder (guard against recursive fallback) in `ui/src/app/detail/detail.component.ts`
**Checkpoint**: Detail page handles all states (loading, error, not-found, success) gracefully and clearly separates read from write content.
---
## Phase 6: User Story 4 — Login Page Matches the Design (Priority: P2)
**Goal**: The login page uses the shared dark design system, displays field-level validation errors and server error messages in `--danger` colour without a page reload, shows a single server error below the form on bad credentials, and disables the button with "Signing in…" label while in-flight.
**Independent Test**: Navigate to `/login` — confirm dark background, surface card, same font as library. Click Sign In with empty fields — confirm field-level errors without page reload. Enter wrong credentials — confirm single error message below form in danger colour; fields retain values. Throttle network and submit valid credentials — confirm button shows "Signing in…" and is disabled.
### Tests for User Story 4 (TDD — write first, confirm failure before T031) ⚠️
- [X] T029 [US4] Add component tests for field-level validation error display on empty submit, `errorMessage` server error paragraph visibility after failed login, "Signing in…" button label while `loading=true`, and fields-not-cleared behaviour in `ui/src/app/login/login.component.spec.ts`
### Implementation for User Story 4
- [X] T030 [P] [US4] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/login/login.component.ts`
- [X] T031 [US4] Centre the login card vertically (`height: 100vh; display: flex; align-items: center; justify-content: center`) and wrap the form in a `--surface` card with `--radius` border-radius and a `1px solid var(--border)` border in `ui/src/app/login/login.component.ts`
- [X] T032 [US4] Apply `color: var(--danger)` to field-level reactive-form validation error `<span>` elements for both username and password fields, and to the `errorMessage` server error paragraph below the form in `ui/src/app/login/login.component.ts`
- [X] T033 [US4] Change submit button label to "Signing in…" and add `disabled` attribute while `loading = true`; style the button with `--accent` background and `--accent-text` foreground matching other views in `ui/src/app/login/login.component.ts`
**Checkpoint**: Login page is visually consistent with the rest of the app and communicates all form states.
---
## Phase 7: User Story 5 — App Shell Is Consistent (Priority: P2)
**Goal**: Every page shares a 48 px fixed-height header with the app name on the left and the sign-out control on the right (when authenticated). The header uses `--surface` background and `--border` bottom border and does not reflow page content on auth state change.
**Independent Test**: Log in and navigate between library, upload, and detail — confirm identical 48 px header on all pages. Log out — confirm sign-out control disappears but header height is unchanged. Visit library without logging in — confirm header is present but sign-out control absent.
### Tests for User Story 5 (TDD — write first, confirm failure before T036) ⚠️
- [X] T034 [US5] Add component tests for header 48 px height, sign-out button visibility when authenticated, sign-out button absence when unauthenticated, sign-out action redirecting to `/login`, and header height unchanged between auth states in `ui/src/app/app.component.spec.ts`
### Implementation for User Story 5
- [X] T035 [P] [US5] Replace all hardcoded colour values with CSS token variables in the component styles block of `ui/src/app/app.component.ts`
- [X] T036 [US5] Style the header `<header>` element with `height: 48px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, and a flex layout placing the app name text on the left and the sign-out button on the right; use `color: var(--text-muted)` and a token-based hover state for the sign-out button; ensure `height` is declared on the host element so the 48 px is preserved regardless of sign-out button visibility in `ui/src/app/app.component.ts`
**Checkpoint**: All five views share a coherent shell. Application feels like one product.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Linting gate, build validation, responsive checks, and visual acceptance walk-through across all milestones.
- [X] T037 [P] Run `ng lint` in `ui/` and confirm zero ESLint and Prettier violations across all modified component files (§7.3 gate)
- [X] T038 [P] Run `ng build` in `ui/` and confirm zero TypeScript or template errors across all modified components
- [ ] T039 [P] Run `ng test --watch=false` in `ui/` (or equivalent build-time check) and confirm all new component spec tests pass with zero regressions
- [ ] T040 [P] Verify upload form layout at 375 px viewport (Chrome DevTools device toolbar → iPhone SE): confirm no horizontal scrollbar and all form elements are usable in `ui/src/app/upload/upload.component.ts`
- [ ] T041 [P] Verify detail page layout at 375 px viewport: confirm image, tags, and (when authenticated) owner actions are all visible without horizontal overflow in `ui/src/app/detail/detail.component.ts`
- [ ] T042 Walk through `specs/005-ui-polish/quickstart.md` scenarios M1M6 in a running `docker compose up` stack and confirm every visual acceptance criterion passes
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user story phases**
- **User Stories (Phases 37)**: ALL depend on Phase 2 (T004 gate); independent of each other
- **Polish (Phase 8)**: Depends on all desired user story phases completing
### User Story Dependencies
- **US1 Library (Phase 3, P1)**: Can start after Phase 2 — no dependency on US2US5
- **US2 Upload (Phase 4, P1)**: Can start after Phase 2 — no dependency on US1, US3US5
- **US3 Detail (Phase 5, P1)**: Can start after Phase 2 — no dependency on US1US2, US4US5
- **US4 Login (Phase 6, P2)**: Can start after Phase 2 — no dependency on US1US3, US5
- **US5 App Shell (Phase 7, P2)**: Can start after Phase 2 — best done last as it wraps all views, but technically independent
### Within Each User Story Phase
1. Write component spec tests first (TDD) — they MUST fail before implementation
2. Token replacement task [P] can run alongside test writing (different files)
3. Implementation tasks follow in sequence (each new feature depends on the test that exercises it)
---
## Parallel Execution Examples
### Running US1 (Library) startup in parallel
```text
# Start simultaneously after Phase 2 completes:
Task T005: Write library component tests (spec file)
Task T006: Apply CSS tokens to library component styles
```
### Running all three P1 stories in parallel (Phase 3, 4, 5)
```text
# All can start simultaneously after T004 (Phase 2 gate):
Phase 3 (US1): T005 → T006/T007 → T008 → T009 → T010 → T011 → T012 → T013
Phase 4 (US2): T014 → T015/T016 → T017 → T018 → T019 → T020
Phase 5 (US3): T021 → T022/T023 → T024 → T025 → T026 → T027 → T028
```
---
## Implementation Strategy
### MVP First (US1 + US2 + US3 — all P1 stories)
1. Complete Phase 1: Baseline verification
2. Complete Phase 2: Design token layer (CRITICAL gate)
3. Complete Phases 3, 4, 5 in parallel or sequence (all P1)
4. **STOP and VALIDATE**: Run quickstart.md M1M4 scenarios
5. Add Phase 6 (US4 Login) and Phase 7 (US5 Shell) for complete polish
### Incremental Delivery
1. Phase 1 + Phase 2 → Token layer live (no visible change)
2. Phase 3 (US1) → Library feels complete → Demo
3. Phase 4 (US2) → Upload flow polished → Demo
4. Phase 5 (US3) → Detail page organised → Demo
5. Phase 6 (US4) + Phase 7 (US5) → Full design system applied → Final demo
6. Phase 8 → Lint clean, build clean, all tests pass, quickstart validated
---
## Notes
- `[P]` tasks have no file conflicts with other concurrent `[P]` tasks in the same phase
- TDD order is mandatory: spec tests must be written and confirmed failing before implementation tasks
- All five component files are standalone Angular components — changes are isolated
- `ng build` is the type-check gate; Karma tests require the full Docker stack for browser runner
- No new npm dependencies are introduced in any task
- Commit after each milestone (M1M6) for clean rollback points

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Header Navigation & Sign-Out Destination
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All items pass. Both changes are small, independent, and clearly bounded. Spec is ready for `/speckit-plan`.

View File

@@ -0,0 +1,25 @@
# Implementation Plan: Header Navigation & Sign-Out Destination
**Branch**: `006-header-nav-signout` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
## Summary
Two targeted changes to `ui/src/app/app.component.ts`:
1. Wrap the header app-name text in a router link to `/`.
2. Change the post-sign-out navigation target from `/login` to `/`.
No API changes. No new dependencies. One component file affected.
## Technical Context
**Language/Version**: TypeScript 5 / Angular 19 (standalone components)
**Affected files**: `ui/src/app/app.component.ts`, `ui/src/app/app.component.spec.ts`
**Testing**: Karma / Jasmine
## Constitution Check
| Principle | Status |
|-----------|--------|
| §2.1 Strict separation of concerns | ✅ UI-only change |
| §5.1 TDD non-negotiable | ✅ Tests written first |
| §7.3 Linting non-optional | ✅ ng lint gate in tasks |

View File

@@ -0,0 +1,69 @@
# Feature Specification: Header Navigation & Sign-Out Destination
**Feature Branch**: `006-header-nav-signout`
**Created**: 2026-05-03
**Status**: Draft
**Input**: User description: "Simple updates to the UI. Site title in the header should link to the base URL so that it's quick to get back to the main grid view from any sub-page now or in the future. When a user signs out, they should be sent back to the grid view instead of the sign in form."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Header Title Links to Grid (Priority: P1)
The owner (or any visitor) is on a sub-page — an image detail page, the upload form, or any future page — and wants to return to the main image grid. Clicking the application name in the header takes them there immediately, without needing the browser back button or a dedicated navigation link.
**Why this priority**: The header title is always visible on every page and is the most natural home-navigation affordance. Making it functional costs almost nothing and benefits every session.
**Independent Test**: Open any sub-page (e.g., an image detail page). Click the application title in the header. Confirm the image grid loads. Works identically whether logged in or not.
**Acceptance Scenarios**:
1. **Given** the user is on any page other than the grid, **When** they click the application title in the header, **Then** they are taken to the image grid view.
2. **Given** the user is already on the grid view, **When** they click the application title, **Then** the page either reloads the grid or stays on it — no error, no blank page.
3. **Given** the user is not logged in and is on a public detail page, **When** they click the application title, **Then** they are taken to the grid (which is publicly visible) without being redirected to login.
---
### User Story 2 — Sign Out Lands on Grid (Priority: P1)
The owner signs out of the application and is returned to the image grid rather than the login page. Since the grid is publicly accessible, there is no need to force a redirect to login — the owner can choose to sign back in if they wish.
**Why this priority**: Sending a signed-out user to the login page is unnecessary friction for a personal tool where the grid content is public. It also makes the sign-out flow feel punitive rather than neutral.
**Independent Test**: Log in and sign out from the header. Confirm the image grid is shown, not the login form. Confirm the grid shows images in read-only mode (no write controls).
**Acceptance Scenarios**:
1. **Given** the user is signed in, **When** they click the sign-out control, **Then** their session ends and they are taken to the image grid view.
2. **Given** the user has landed on the grid after signing out, **When** they view the page, **Then** tag-edit and delete controls are not shown (consistent with unauthenticated behaviour already in place).
3. **Given** the user signs out from a sub-page (e.g., detail page), **When** the sign-out completes, **Then** they are taken to the grid — not to the page they were on, and not to the login form.
---
### Edge Cases
- What happens if the grid is unavailable when the user clicks the title? The navigation attempt is made; any existing error-state handling on the grid covers this.
- What if a future page introduces an auth-required route? The header title links to the grid unconditionally; auth guards on specific routes handle their own redirects independently.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The application title displayed in the persistent header MUST be a navigable link that takes the user to the image grid view from any page in the application.
- **FR-002**: The title link MUST be accessible to both authenticated and unauthenticated users; it MUST NOT trigger a login redirect.
- **FR-003**: After a user successfully signs out, the application MUST navigate them to the image grid view.
- **FR-004**: The sign-out destination MUST be the grid view regardless of which page the user was on when they signed out.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: From any page, the image grid is reachable in exactly one click via the header title — no intermediate pages or redirects.
- **SC-002**: After signing out, the user sees the image grid (not the login page) in 100% of sign-out flows.
- **SC-003**: The title link functions correctly for both authenticated and unauthenticated sessions — verified across the grid, detail, and upload pages.
## Assumptions
- The image grid at the root URL is publicly accessible without authentication (confirmed: existing behaviour shows images to unauthenticated visitors).
- The application title ("Reactbin") is already rendered as a text element in the persistent header from the UI polish work; this spec adds navigation behaviour to it, not a new visual element.
- No change is made to the login redirect behaviour for protected routes (e.g., navigating directly to `/upload` while logged out still redirects to login as before).
- The sign-out action clears the session as already implemented; only the post-sign-out destination changes.

View File

@@ -0,0 +1,15 @@
# Tasks: Header Navigation & Sign-Out Destination
## Phase 1: Tests (TDD — write first)
- [X] T001 Add component tests for header title routerLink to `/` and sign-out navigation to `/` in `ui/src/app/app.component.spec.ts`
## Phase 2: Implementation
- [X] T002 Wrap the `.app-name` span in a `routerLink="/"` anchor in `ui/src/app/app.component.ts`
- [X] T003 Change `onLogout()` navigation target from `/login` to `/` in `ui/src/app/app.component.ts`
## Phase 3: Validation
- [X] T004 Run `ng lint` in `ui/` — zero violations
- [X] T005 Run `ng build` in `ui/` — zero errors

4
ui/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
coverage/
package-lock.json

View File

@@ -4,6 +4,9 @@ const tseslint = require("typescript-eslint");
const angular = require("angular-eslint"); const angular = require("angular-eslint");
module.exports = tseslint.config( module.exports = tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", "*.min.js"],
},
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
extends: [ extends: [

16734
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,95 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { provideRouter } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { AuthService } from './auth/auth.service';
describe('AppComponent', () => { describe('AppComponent', () => {
let authSpy: jasmine.SpyObj<AuthService>;
beforeEach(async () => { beforeEach(async () => {
authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated', 'logout']);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppComponent], imports: [AppComponent],
providers: [provideRouter(routes)], providers: [
provideRouter(routes),
{ provide: AuthService, useValue: authSpy },
],
}).compileComponents(); }).compileComponents();
}); });
it('should create the app', () => { it('should create the app', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; expect(fixture.componentInstance).toBeTruthy();
expect(app).toBeTruthy();
}); });
it('should have title reactbin-ui', () => { it('should have title reactbin-ui', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; expect(fixture.componentInstance.title).toEqual('reactbin-ui');
expect(app.title).toEqual('reactbin-ui'); });
it('header is present when authenticated', () => {
authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
expect(header).not.toBeNull();
});
it('header is present when not authenticated', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header');
expect(header).not.toBeNull();
});
it('sign-out button is visible when authenticated', () => {
authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
expect(btn).not.toBeNull();
});
it('sign-out button is absent when not authenticated', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const btn = (fixture.nativeElement as HTMLElement).querySelector('.logout-btn');
expect(btn).toBeNull();
});
it('onLogout calls auth.logout and navigates to / (grid)', () => {
authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
fixture.componentInstance.onLogout();
expect(authSpy.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('header app-name is a link to /', () => {
authSpy.isAuthenticated.and.returnValue(false);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('a.app-name') as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe('/');
});
it('header height is 48px', () => {
authSpy.isAuthenticated.and.returnValue(true);
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const header = (fixture.nativeElement as HTMLElement).querySelector('header.app-header') as HTMLElement;
// The CSS declares height: 48px; we verify the class is applied correctly via the element presence
// (actual computed styles require a real browser/DOM environment)
expect(header.classList.contains('app-header')).toBeTrue();
}); });
}); });

View File

@@ -1,12 +1,51 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { CommonModule } from '@angular/common';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet], imports: [CommonModule, RouterLink, RouterOutlet],
template: `<router-outlet />`, template: `
<header class="app-header">
<a routerLink="/" class="app-name">Reactbin</a>
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
</header>
<router-outlet />
`,
styles: [`
:host { display: block; }
.app-header {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.app-name { font-weight: 600; font-size: 1rem; color: var(--text); letter-spacing: 0.02em; text-decoration: none; }
.logout-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 12px;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
transition: border-color var(--transition), color var(--transition);
}
.logout-btn:hover { border-color: var(--border-focus); color: var(--text); }
`],
}) })
export class AppComponent { export class AppComponent {
title = 'reactbin-ui'; title = 'reactbin-ui';
constructor(public auth: AuthService, private router: Router) {}
onLogout(): void {
this.auth.logout();
this.router.navigate(['/']);
}
} }

View File

@@ -1,13 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
provideHttpClient(), provideHttpClient(withInterceptors([authInterceptor])),
], ],
}; };

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -6,8 +7,14 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./library/library.component').then((m) => m.LibraryComponent), import('./library/library.component').then((m) => m.LibraryComponent),
}, },
{
path: 'login',
loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent),
},
{ {
path: 'upload', path: 'upload',
canActivate: [authGuard],
loadComponent: () => loadComponent: () =>
import('./upload/upload.component').then((m) => m.UploadComponent), import('./upload/upload.component').then((m) => m.UploadComponent),
}, },

View File

@@ -0,0 +1,31 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { provideLocationMocks } from '@angular/common/testing';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('authGuard', () => {
let authService: jasmine.SpyObj<AuthService>;
beforeEach(() => {
authService = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideLocationMocks(),
{ provide: AuthService, useValue: authService },
],
});
});
it('redirects to login when not authenticated', () => {
authService.isAuthenticated.and.returnValue(false);
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/upload' } as RouterStateSnapshot;
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
expect(result).toBeTruthy();
const urlTree = result as ReturnType<Router['createUrlTree']>;
expect(urlTree.toString()).toContain('/login');
});
});

View File

@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};

View File

@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
authService = jasmine.createSpyObj('AuthService', ['getToken', 'logout']);
router = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('adds Authorization header when authenticated', () => {
authService.getToken.and.returnValue('test-token');
http.get('/api/v1/images').subscribe();
const req = httpMock.expectOne('/api/v1/images');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush([]);
});
it('does not add Authorization header when not authenticated', () => {
authService.getToken.and.returnValue(null);
http.get('/api/v1/images').subscribe();
const req = httpMock.expectOne('/api/v1/images');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush([]);
});
it('redirects to login on 401 response', () => {
authService.getToken.and.returnValue('test-token');
// eslint-disable-next-line @typescript-eslint/no-empty-function
http.get('/api/v1/images').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/images');
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
});

View File

@@ -0,0 +1,23 @@
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.getToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req).pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse && err.status === 401) {
auth.logout();
router.navigate(['/login']);
}
return throwError(() => err);
}),
);
};

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
sessionStorage.clear();
});
it('login stores token in sessionStorage', (done) => {
service.login('owner', 'password').subscribe(() => {
expect(sessionStorage.getItem('auth_token')).toBe('test-token');
done();
});
const req = httpMock.expectOne('/api/v1/auth/token');
expect(req.request.method).toBe('POST');
req.flush({ access_token: 'test-token', token_type: 'bearer', expires_in: 3600 });
});
it('isAuthenticated returns true when token is present', () => {
sessionStorage.setItem('auth_token', 'some-token');
expect(service.isAuthenticated()).toBeTrue();
});
it('isAuthenticated returns false when no token', () => {
expect(service.isAuthenticated()).toBeFalse();
});
// US4 logout tests (T025)
it('logout removes token from sessionStorage', () => {
sessionStorage.setItem('auth_token', 'tok');
service.logout();
expect(sessionStorage.getItem('auth_token')).toBeNull();
});
it('isAuthenticated returns false after logout', () => {
sessionStorage.setItem('auth_token', 'tok');
service.logout();
expect(service.isAuthenticated()).toBeFalse();
});
});

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map, tap } from 'rxjs';
const TOKEN_KEY = 'auth_token';
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private http: HttpClient) {}
login(username: string, password: string): Observable<void> {
return this.http
.post<TokenResponse>('/api/v1/auth/token', { username, password })
.pipe(
tap((res) => sessionStorage.setItem(TOKEN_KEY, res.access_token)),
map(() => undefined),
);
}
logout(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return sessionStorage.getItem(TOKEN_KEY);
}
isAuthenticated(): boolean {
return this.getToken() !== null;
}
}

View File

@@ -2,27 +2,19 @@ import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs'; import { of, throwError, Subject } from 'rxjs';
import { DetailComponent } from './detail.component'; import { DetailComponent } from './detail.component';
import { ImageService } from '../services/image.service'; import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
const MOCK_IMAGE = { const MOCK_IMAGE = {
id: 'img-1', id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
hash: 'abc', size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
filename: 'test.jpg', thumbnail_key: null, created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
mime_type: 'image/jpeg',
size_bytes: 100,
width: 10,
height: 10,
storage_key: 'abc',
thumbnail_key: null,
created_at: '2026-01-01T00:00:00Z',
tags: ['cat', 'funny'],
}; };
describe('DetailComponent', () => { describe('DetailComponent', () => {
function setup(imageId = 'img-1') { function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DetailComponent], imports: [DetailComponent],
providers: [ providers: [
@@ -33,13 +25,13 @@ describe('DetailComponent', () => {
], ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(DetailComponent); const fixture = TestBed.createComponent(DetailComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService); const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE)); spyOn(imgSvc, 'get').and.returnValue(imageResponse);
fixture.detectChanges(); fixture.detectChanges();
return { fixture, component, imgSvc }; return { fixture, component: fixture.componentInstance, imgSvc };
} }
// Existing tests preserved
it('should call PATCH with removed tag absent when chip × is clicked', () => { it('should call PATCH with removed tag absent when chip × is clicked', () => {
const { component, imgSvc } = setup(); const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] })); spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
@@ -80,4 +72,74 @@ describe('DetailComponent', () => {
component.goBack(); component.goBack();
expect(router.navigate).toHaveBeenCalledWith(['/']); expect(router.navigate).toHaveBeenCalledWith(['/']);
}); });
// New polish tests
it('skeleton is visible while loading is true', () => {
TestBed.configureTestingModule({
imports: [DetailComponent],
providers: [
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(DetailComponent);
const imgSvc = TestBed.inject(ImageService);
// Don't emit — keep loading state
spyOn(imgSvc, 'get').and.returnValue(new Subject());
fixture.componentInstance.loading = true;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.image-skeleton')).not.toBeNull();
});
it('error card shown when error is true and loading is false', () => {
const fixture = (() => {
TestBed.configureTestingModule({
imports: [DetailComponent],
providers: [
provideHttpClient(), provideHttpClientTesting(), provideRouter(routes),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'img-1' } } } },
],
}).compileComponents();
return TestBed.createComponent(DetailComponent);
})();
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'get').and.returnValue(throwError(() => ({ status: 500 })));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.fetch-error-card')).not.toBeNull();
});
it('not-found card shown when image is null, loading is false, error is false', () => {
const { fixture, component } = setup('img-1', of(MOCK_IMAGE));
component.image = null;
component.loading = false;
component.error = false;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.not-found-card')).not.toBeNull();
});
it('tag error element uses danger styling class', () => {
const { fixture, component } = setup();
component.tagError = 'Invalid tag: special characters not allowed';
fixture.detectChanges();
const errEl = (fixture.nativeElement as HTMLElement).querySelector('.tag-error');
expect(errEl).not.toBeNull();
});
it('onImgError sets src to placeholder SVG', () => {
const { fixture } = setup();
const imgEl = document.createElement('img');
imgEl.src = 'http://example.com/image.jpg';
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
expect(imgEl.src).toContain('data:image/svg+xml');
});
it('onImgError does not recurse when src already is a data URI', () => {
const { fixture } = setup();
const imgEl = document.createElement('img');
imgEl.src = 'data:image/svg+xml,placeholder';
const before = imgEl.src;
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
expect(imgEl.src).toBe(before);
});
}); });

View File

@@ -1,28 +1,70 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
import { AuthService } from '../auth/auth.service';
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">&#x1F517;</text></svg>`;
@Component({ @Component({
selector: 'app-detail', selector: 'app-detail',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="detail-page" *ngIf="image"> <!-- Loading skeleton -->
<button class="back-btn" (click)="goBack()">← Back</button> <div class="detail-page" *ngIf="loading">
<div class="skeleton image-skeleton"></div>
<div class="chip-row-skeleton">
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton"></div>
</div>
<div class="chip-row-skeleton">
<div *ngFor="let _ of skeletonChips" class="skeleton chip-skeleton short"></div>
</div>
</div>
<!-- Network error state -->
<div class="fetch-error-card" *ngIf="error && !loading">
<span class="error-icon">⚠</span>
<p>Failed to load image. Please check your connection.</p>
<div class="error-actions">
<button class="retry-btn" (click)="retry()">Retry</button>
<a routerLink="/" class="back-link">Back to library</a>
</div>
</div>
<!-- Not-found state -->
<div class="not-found-card" *ngIf="!image && !loading && !error">
<span class="not-found-icon">✦</span>
<h2>Image not found</h2>
<p>This image may have been deleted or the URL is incorrect.</p>
<a routerLink="/" class="back-btn">Back to library</a>
</div>
<!-- Main content -->
<div class="detail-page" *ngIf="image && !loading">
<button class="back-btn-inline" (click)="goBack()">← Back</button>
<h2>{{ image.filename }}</h2> <h2>{{ image.filename }}</h2>
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" /> <img
class="full-image"
[src]="imageService.getFileUrl(image.id)"
[alt]="image.filename"
(error)="onImgError($event)"
/>
<section class="tags-section"> <section class="tags-section">
<h3>Tags</h3> <h3>Tags</h3>
<div class="chips"> <div class="chips">
<span *ngFor="let tag of image.tags" class="chip"> <span *ngFor="let tag of image.tags" class="chip">
{{ tag }} <button (click)="removeTag(tag)">×</button> {{ tag }} <button *ngIf="auth.isAuthenticated()" (click)="removeTag(tag)">×</button>
</span> </span>
</div> </div>
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
</section>
<section class="owner-actions" *ngIf="auth.isAuthenticated()">
<div class="add-tag"> <div class="add-tag">
<input <input
[(ngModel)]="newTagInput" [(ngModel)]="newTagInput"
@@ -31,11 +73,9 @@ import { ImageRecord, ImageService } from '../services/image.service';
(blur)="onBlur()" (blur)="onBlur()"
/> />
</div> </div>
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p> <button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
</section> </section>
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
<div class="dialog-overlay" *ngIf="showDeleteDialog"> <div class="dialog-overlay" *ngIf="showDeleteDialog">
<div class="dialog"> <div class="dialog">
<p>Permanently delete this image?</p> <p>Permanently delete this image?</p>
@@ -44,37 +84,82 @@ import { ImageRecord, ImageService } from '../services/image.service';
</div> </div>
</div> </div>
</div> </div>
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
`, `,
styles: [` styles: [`
.detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; } .detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; }
.back-btn { background: none; border: none; color: #4a9eff; cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: 8px; display: block; } /* Skeleton */
.image-skeleton { width: 100%; height: 400px; margin-bottom: 16px; }
.chip-row-skeleton { display: flex; gap: 8px; margin-bottom: 10px; padding: 0 16px; }
.chip-skeleton { width: 64px; height: 28px; border-radius: var(--radius-chip); }
.chip-skeleton.short { width: 48px; }
/* Network error card */
.fetch-error-card {
max-width: 520px; margin: 80px auto; text-align: center;
padding: 40px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.error-icon { display: block; font-size: 2rem; color: var(--danger); margin-bottom: 12px; }
.error-actions { display: flex; justify-content: center; gap: 16px; margin-top: 20px; }
.retry-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
.retry-btn:hover { border-color: var(--border-focus); }
.back-link { color: var(--accent); text-decoration: none; line-height: 2.2; }
/* Not-found card */
.not-found-card {
max-width: 480px; margin: 80px auto; text-align: center;
padding: 48px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.not-found-icon { display: block; font-size: 2.5rem; color: var(--text-muted); margin-bottom: 16px; }
.not-found-card h2 { margin-bottom: 8px; }
.not-found-card p { color: var(--text-muted); margin-bottom: 24px; }
/* Main detail */
.back-btn-inline { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; }
.back-btn {
display: inline-block; margin-top: 16px; padding: 10px 24px;
background: var(--surface-raised); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; text-decoration: none;
}
.back-btn:hover { border-color: var(--border-focus); }
.full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: var(--radius); display: block; }
.tags-section { margin-top: 24px; } .tags-section { margin-top: 24px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.chip { background: #333; padding: 4px 12px; border-radius: 14px; display: flex; align-items: center; gap: 6px; } .chip { background: var(--surface-raised); padding: 4px 12px; border-radius: var(--radius-chip); display: flex; align-items: center; gap: 6px; }
.chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; } .chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1rem; }
.add-tag input { padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; width: 200px; } .tag-error { color: var(--danger); font-size: 0.85rem; margin-top: 6px; border-left: 3px solid var(--danger); padding-left: 10px; }
.tag-error { color: #ff6b6b; font-size: 0.85rem; margin-top: 6px; }
.delete-btn { margin-top: 32px; padding: 10px 24px; background: #c0392b; color: #fff; border: none; border-radius: 6px; cursor: pointer; } /* Owner actions panel */
.owner-actions {
margin-top: 24px; padding: 20px;
background: var(--surface); border-top: 1px solid var(--border); border-radius: var(--radius);
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
}
.add-tag input { padding: 8px; background: var(--surface-raised); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); width: 200px; }
.add-tag input:focus { outline: none; border-color: var(--border-focus); }
.delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
/* Delete dialog */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; } .dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
.dialog { background: #1a1a1a; padding: 32px; border-radius: 10px; text-align: center; } .dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
.dialog button { margin: 0 8px; padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; } .dialog button { margin: 12px 8px 0; padding: 8px 20px; border: none; border-radius: var(--radius); cursor: pointer; }
.dialog button:first-of-type { background: #c0392b; color: #fff; } .dialog button:first-of-type { background: var(--danger); color: var(--danger-text); }
.dialog button:last-of-type { background: #444; color: #e0e0e0; } .dialog button:last-of-type { background: var(--surface-raised); color: var(--text); }
.not-found { text-align: center; color: #666; padding: 60px; }
`], `],
}) })
export class DetailComponent implements OnInit { export class DetailComponent implements OnInit {
image: ImageRecord | null = null; image: ImageRecord | null = null;
loading = true; loading = true;
error = false;
newTagInput = ''; newTagInput = '';
tagError = ''; tagError = '';
showDeleteDialog = false; showDeleteDialog = false;
readonly skeletonChips = Array(4).fill(null);
private currentId = '';
constructor( constructor(
public imageService: ImageService, public imageService: ImageService,
public auth: AuthService,
private route: ActivatedRoute, private route: ActivatedRoute,
public router: Router, public router: Router,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
@@ -83,9 +168,35 @@ export class DetailComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
if (!id) { this.loading = false; return; } if (!id) { this.loading = false; return; }
this.currentId = id;
this.fetchImage(id);
}
retry(): void {
this.error = false;
this.loading = true;
this.cdr.markForCheck();
this.fetchImage(this.currentId);
}
private fetchImage(id: string): void {
this.imageService.get(id).subscribe({ this.imageService.get(id).subscribe({
next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); }, next: (img) => {
error: () => { this.loading = false; this.cdr.markForCheck(); }, this.image = img;
this.loading = false;
this.error = false;
this.cdr.markForCheck();
},
error: (err) => {
this.loading = false;
if (err?.status === 404) {
this.image = null;
this.error = false;
} else {
this.error = true;
}
this.cdr.markForCheck();
},
}); });
} }
@@ -125,4 +236,11 @@ export class DetailComponent implements OnInit {
} }
goBack(): void { this.router.navigate(['/']); } goBack(): void { this.router.navigate(['/']); }
onImgError(event: Event): void {
const img = event.target as HTMLImageElement;
if (!img.src.startsWith('data:')) {
img.src = PLACEHOLDER_SVG;
}
}
} }

View File

@@ -1,12 +1,18 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { LibraryComponent } from './library.component'; import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service'; import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 };
const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' }],
total: 1, limit: 50, offset: 0,
};
describe('LibraryComponent', () => { describe('LibraryComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -17,33 +23,88 @@ describe('LibraryComponent', () => {
it('should render image grid from service response', () => { it('should render image grid from service response', () => {
const fixture = TestBed.createComponent(LibraryComponent); const fixture = TestBed.createComponent(LibraryComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService); const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue( spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
of({
items: [
{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' },
],
total: 1,
limit: 50,
offset: 0,
})
);
fixture.detectChanges(); fixture.detectChanges();
const de = fixture.nativeElement as HTMLElement; expect((fixture.nativeElement as HTMLElement).querySelectorAll('.image-card').length).toBe(1);
expect(de.querySelectorAll('.image-card').length).toBe(1);
}); });
it('should trigger new API call with tags param on filter change', () => { it('should trigger new API call with tags param on filter change', () => {
const fixture = TestBed.createComponent(LibraryComponent); const fixture = TestBed.createComponent(LibraryComponent);
const component = fixture.componentInstance;
const imgSvc = TestBed.inject(ImageService); const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue( const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
of({ items: [], total: 0, limit: 50, offset: 0 })
);
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance.applyFilter(['cat', 'funny']);
component.applyFilter(['cat', 'funny']);
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number)); expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
}); });
it('showSpinner is false initially', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.showSpinner).toBeFalse();
});
it('renders 8 skeleton cards while showSpinner is true', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.showSpinner = true;
fixture.detectChanges();
const skeletons = (fixture.nativeElement as HTMLElement).querySelectorAll('.card-skeleton');
expect(skeletons.length).toBe(8);
});
it('error is false initially', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.error).toBeFalse();
});
it('shows error card when error is true', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
});
it('error card has retry button that calls load()', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true;
fixture.detectChanges();
spyOn(fixture.componentInstance, 'load');
const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
expect(retryBtn).not.toBeNull();
retryBtn.click();
expect(fixture.componentInstance.load).toHaveBeenCalled();
});
it('empty state contains routerLink to /upload', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('.empty-state a[href="/upload"]');
expect(link).not.toBeNull();
});
it('onImgError sets src to placeholder SVG', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgEl = document.createElement('img');
imgEl.src = 'http://example.com/image.jpg';
const event = { target: imgEl } as unknown as Event;
fixture.componentInstance.onImgError(event);
expect(imgEl.src).toContain('data:image/svg+xml');
});
it('onImgError does not recurse when src already contains placeholder', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgEl = document.createElement('img');
imgEl.src = 'data:image/svg+xml,placeholder';
const originalSrc = imgEl.src;
const event = { target: imgEl } as unknown as Event;
fixture.componentInstance.onImgError(event);
expect(imgEl.src).toBe(originalSrc);
});
}); });

View File

@@ -5,15 +5,18 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
import { TagService } from '../services/tag.service'; import { TagService } from '../services/tag.service';
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="160" viewBox="0 0 200 160"><rect width="200" height="160" fill="%23252525"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="32" fill="%23555">&#x1F5BC;</text></svg>`;
@Component({ @Component({
selector: 'app-library', selector: 'app-library',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="library"> <div class="library">
@@ -34,49 +37,83 @@ import { TagService } from '../services/tag.service';
</span> </span>
</div> </div>
<ul class="suggestions" *ngIf="suggestions.length"> <ul class="suggestions" *ngIf="suggestions.length">
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)">{{ s.name }} ({{ s.image_count }})</li> <li *ngFor="let s of suggestions" (click)="addFilter(s.name)" (keydown.enter)="addFilter(s.name)" tabindex="0" role="option" [attr.aria-selected]="false">{{ s.name }} ({{ s.image_count }})</li>
</ul> </ul>
</div> </div>
<div *ngIf="images.length === 0 && !loading" class="empty-state"> <!-- Skeleton loading grid -->
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p> <div *ngIf="showSpinner" class="grid">
<div *ngFor="let _ of skeletonItems" class="image-card skeleton card-skeleton"></div>
</div> </div>
<div class="grid"> <!-- Error state -->
<div *ngIf="error && !showSpinner" class="error-card">
<p>Failed to load images. Please check your connection.</p>
<button class="retry-btn" (click)="load()">Retry</button>
</div>
<!-- Empty state -->
<div *ngIf="images.length === 0 && !showSpinner && !error" class="empty-state">
<span class="empty-icon">✦</span>
<p *ngIf="activeFilters.length">No images match these filters.</p>
<p *ngIf="!activeFilters.length">No images yet.</p>
<a *ngIf="!activeFilters.length" routerLink="/upload" class="upload-link">Upload your first image</a>
</div>
<!-- Image grid -->
<div *ngIf="!showSpinner && !error" class="grid">
<div <div
*ngFor="let img of images" *ngFor="let img of images"
class="image-card" class="image-card"
role="button"
tabindex="0"
(click)="router.navigate(['/images', img.id])" (click)="router.navigate(['/images', img.id])"
(keydown.enter)="router.navigate(['/images', img.id])"
> >
<img [src]="imageService.getThumbnailUrl(img.id)" [alt]="img.filename" loading="lazy" /> <img
[src]="imageService.getThumbnailUrl(img.id)"
[alt]="img.filename"
loading="lazy"
(error)="onImgError($event)"
/>
<div class="tag-row"> <div class="tag-row">
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span> <span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
</div> </div>
</div> </div>
</div> </div>
<button *ngIf="hasMore" class="load-more" (click)="loadMore()">Load more</button> <button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
</div> </div>
`, `,
styles: [` styles: [`
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; } .library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.upload-btn { padding: 8px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; } .upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; }
.filter-bar { position: relative; margin-bottom: 24px; } .filter-bar { position: relative; margin-bottom: 24px; }
.filter-bar input { width: 100%; padding: 10px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 6px; } .filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
.filter-bar input:focus { outline: none; border-color: var(--border-focus); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: #333; padding: 3px 10px; border-radius: 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px; } .chip { background: var(--surface-raised); padding: 3px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; display: flex; align-items: center; gap: 4px; }
.chip.small { font-size: 0.75rem; padding: 2px 8px; } .chip.small { font-size: 0.75rem; padding: 2px 8px; }
.chip button { background: none; border: none; color: #aaa; cursor: pointer; padding: 0; font-size: 1rem; } .chip button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1rem; }
.suggestions { position: absolute; z-index: 10; background: #1a1a1a; border: 1px solid #444; list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 6px 6px; } .suggestions { position: absolute; z-index: 10; background: var(--surface); border: 1px solid var(--border); list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 var(--radius) var(--radius); }
.suggestions li { padding: 8px 12px; cursor: pointer; } .suggestions li { padding: 8px 12px; cursor: pointer; }
.suggestions li:hover { background: #2a2a2a; } .suggestions li:hover { background: var(--surface-raised); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; } .image-card { cursor: pointer; background: var(--surface); border-radius: var(--radius); overflow: hidden; transition: transform var(--transition), box-shadow var(--transition); }
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; } .image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card-skeleton { height: 200px; }
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; } .tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.empty-state { text-align: center; padding: 60px 0; color: #666; } .empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; cursor: pointer; } .empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
.upload-link { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
.upload-link:hover { text-decoration: underline; }
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.retry-btn:hover { border-color: var(--border-focus); }
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
`], `],
}) })
export class LibraryComponent implements OnInit { export class LibraryComponent implements OnInit {
@@ -84,8 +121,10 @@ export class LibraryComponent implements OnInit {
activeFilters: string[] = []; activeFilters: string[] = [];
tagSearch = ''; tagSearch = '';
suggestions: { name: string; image_count: number }[] = []; suggestions: { name: string; image_count: number }[] = [];
loading = false; showSpinner = false;
error = false;
hasMore = false; hasMore = false;
readonly skeletonItems = Array(8).fill(null);
private offset = 0; private offset = 0;
private readonly limit = 50; private readonly limit = 50;
private readonly filterChange$ = new Subject<string>(); private readonly filterChange$ = new Subject<string>();
@@ -98,7 +137,7 @@ export class LibraryComponent implements OnInit {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.loadImages(); this.load();
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => { this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) { if (q) {
this.tagService.list(q, 10).subscribe((r) => { this.tagService.list(q, 10).subscribe((r) => {
@@ -112,6 +151,29 @@ export class LibraryComponent implements OnInit {
}); });
} }
load(): void {
this.error = false;
const req$ = this.imageService.list(this.activeFilters, this.limit, this.offset).pipe(share());
timer(150).pipe(takeUntil(req$)).subscribe(() => {
this.showSpinner = true;
this.cdr.markForCheck();
});
req$.subscribe({
next: (res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.showSpinner = false;
this.cdr.markForCheck();
},
error: () => {
this.showSpinner = false;
this.error = true;
this.cdr.markForCheck();
},
});
}
onTagInput(event: Event): void { onTagInput(event: Event): void {
const val = (event.target as HTMLInputElement).value; const val = (event.target as HTMLInputElement).value;
this.tagSearch = val; this.tagSearch = val;
@@ -136,21 +198,17 @@ export class LibraryComponent implements OnInit {
this.activeFilters = tags; this.activeFilters = tags;
this.offset = 0; this.offset = 0;
this.images = []; this.images = [];
this.loadImages(); this.load();
}
loadImages(): void {
this.loading = true;
this.imageService.list(this.activeFilters, this.limit, this.offset).subscribe((res) => {
this.images = [...this.images, ...res.items];
this.offset += res.items.length;
this.hasMore = this.offset < res.total;
this.loading = false;
this.cdr.markForCheck();
});
} }
loadMore(): void { loadMore(): void {
this.loadImages(); this.load();
}
onImgError(event: Event): void {
const img = event.target as HTMLImageElement;
if (!img.src.startsWith('data:')) {
img.src = PLACEHOLDER_SVG;
}
} }
} }

View File

@@ -0,0 +1,107 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { LoginComponent } from './login.component';
import { AuthService } from '../auth/auth.service';
describe('LoginComponent', () => {
let component: LoginComponent;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(async () => {
authService = jasmine.createSpyObj('AuthService', ['login']);
router = jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']);
await TestBed.configureTestingModule({
imports: [LoginComponent, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('submit calls AuthService.login with username and password', fakeAsync(() => {
authService.login.and.returnValue(of(undefined));
component.form.setValue({ username: 'owner', password: 'hunter2' });
component.onSubmit();
tick();
expect(authService.login).toHaveBeenCalledWith('owner', 'hunter2');
}));
it('navigates to library on success', fakeAsync(() => {
authService.login.and.returnValue(of(undefined));
router.navigateByUrl.and.returnValue(Promise.resolve(true));
component.form.setValue({ username: 'owner', password: 'hunter2' });
component.onSubmit();
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
}));
it('shows error message on 401', fakeAsync(() => {
const err = new HttpErrorResponse({ status: 401 });
authService.login.and.returnValue(throwError(() => err));
component.form.setValue({ username: 'owner', password: 'wrong' });
component.onSubmit();
tick();
expect(component.errorMessage).toBeTruthy();
}));
it('does not call login when fields are empty', fakeAsync(() => {
component.form.setValue({ username: '', password: '' });
component.onSubmit();
tick();
expect(authService.login).not.toHaveBeenCalled();
}));
// New polish tests
it('submit button shows "Signing in…" and is disabled while loading', () => {
const fixture = TestBed.createComponent(LoginComponent);
const comp = fixture.componentInstance;
fixture.detectChanges();
comp.loading = true;
fixture.detectChanges();
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
expect(btn.textContent?.trim()).toContain('Signing in');
expect(btn.disabled).toBeTrue();
});
it('field-level validation error shown for empty username on touched', () => {
const fixture = TestBed.createComponent(LoginComponent);
const comp = fixture.componentInstance;
fixture.detectChanges();
comp.form.get('username')!.markAsTouched();
fixture.detectChanges();
const err = (fixture.nativeElement as HTMLElement).querySelector('.validation-error');
expect(err).not.toBeNull();
});
it('errorMessage paragraph is visible when errorMessage is set', fakeAsync(() => {
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.form.setValue({ username: 'owner', password: 'wrong' });
component.onSubmit();
tick();
const fixture = TestBed.createComponent(LoginComponent);
fixture.componentInstance.errorMessage = component.errorMessage;
fixture.detectChanges();
const errPara = (fixture.nativeElement as HTMLElement).querySelector('.error-message');
expect(errPara).not.toBeNull();
}));
it('fields retain their values after a failed login', fakeAsync(() => {
authService.login.and.returnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.form.setValue({ username: 'owner', password: 'wrong' });
component.onSubmit();
tick();
expect(component.form.value.username).toBe('owner');
expect(component.form.value.password).toBe('wrong');
}));
});

View File

@@ -0,0 +1,114 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<div class="login-page">
<div class="login-card">
<h1>Sign In</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<div class="field">
<label for="username">Username</label>
<input id="username" type="text" formControlName="username" autocomplete="username" />
<span *ngIf="form.get('username')?.invalid && form.get('username')?.touched" class="validation-error">
Username is required
</span>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
<span *ngIf="form.get('password')?.invalid && form.get('password')?.touched" class="validation-error">
Password is required
</span>
</div>
<p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
<button type="submit" [disabled]="loading">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</div>
`,
styles: [`
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: var(--bg);
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 40px 32px;
}
h1 { margin-bottom: 28px; font-size: 1.5rem; }
.field { margin-bottom: 20px; }
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: var(--text-muted); }
input[type="text"], input[type="password"] {
width: 100%; padding: 10px 12px;
background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius);
font-size: 1rem; transition: border-color var(--transition);
}
input:focus { outline: none; border-color: var(--border-focus); }
.validation-error { display: block; margin-top: 4px; font-size: 0.8rem; color: var(--danger); }
.error-message { margin-bottom: 16px; color: var(--danger); font-size: 0.9rem; }
button[type="submit"] {
width: 100%; padding: 11px;
background: var(--accent); color: var(--accent-text);
border: none; border-radius: var(--radius);
font-size: 1rem; font-weight: 600; cursor: pointer;
transition: opacity var(--transition);
}
button[type="submit"]:disabled { opacity: 0.5; cursor: default; }
`],
})
export class LoginComponent {
form: FormGroup;
loading = false;
errorMessage = '';
constructor(
private fb: FormBuilder,
private auth: AuthService,
private router: Router,
private route: ActivatedRoute,
) {
this.form = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
}
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.errorMessage = '';
const { username, password } = this.form.value;
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/';
this.auth.login(username, password).subscribe({
next: () => {
this.loading = false;
this.router.navigateByUrl(returnUrl);
},
error: () => {
this.loading = false;
this.errorMessage = 'Invalid username or password';
},
});
}
}

View File

@@ -2,19 +2,12 @@ import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of, throwError } from 'rxjs';
import { UploadComponent } from './upload.component'; import { UploadComponent } from './upload.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
describe('UploadComponent', () => { describe('UploadComponent', () => {
let component: UploadComponent; let component: UploadComponent;
function makeImageService(overrides: Partial<ImageService> = {}): jasmine.SpyObj<ImageService> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return jasmine.createSpyObj<ImageService>('ImageService', { upload: of({} as any), ...overrides } as any);
}
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [UploadComponent], imports: [UploadComponent],
@@ -26,22 +19,17 @@ describe('UploadComponent', () => {
const fixture = TestBed.createComponent(UploadComponent); const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.tagInput = 'CAT, Funny reaction'; component.tagInput = 'CAT, Funny reaction';
const parsed = component.parseTagInput(component.tagInput); expect(component.parseTagInput(component.tagInput)).toEqual(['cat', 'funny', 'reaction']);
expect(parsed).toEqual(['cat', 'funny', 'reaction']);
}); });
it('should split on commas', () => { it('should split on commas', () => {
const fixture = TestBed.createComponent(UploadComponent); const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance; expect(fixture.componentInstance.parseTagInput('a,b,c')).toEqual(['a', 'b', 'c']);
const parsed = component.parseTagInput('a,b,c');
expect(parsed).toEqual(['a', 'b', 'c']);
}); });
it('should filter empty tokens', () => { it('should filter empty tokens', () => {
const fixture = TestBed.createComponent(UploadComponent); const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance; expect(fixture.componentInstance.parseTagInput(' ,, cat ,,')).toEqual(['cat']);
const parsed = component.parseTagInput(' ,, cat ,,');
expect(parsed).toEqual(['cat']);
}); });
it('on duplicate response: shows toast and navigates to detail', async () => { it('on duplicate response: shows toast and navigates to detail', async () => {
@@ -49,13 +37,7 @@ describe('UploadComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
spyOn(router, 'navigate'); spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
const mockSvc = makeImageService({
upload: of({ id: 'abc', duplicate: true } as any),
} as any);
(component as any).imageService = mockSvc;
await component.handleUploadResponse({ id: 'abc', duplicate: true } as any);
expect(component.toastMessage).toContain('library'); expect(component.toastMessage).toContain('library');
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']); expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
}); });
@@ -65,8 +47,7 @@ describe('UploadComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
spyOn(router, 'navigate'); spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any);
expect(component.toastMessage).toBeTruthy(); expect(component.toastMessage).toBeTruthy();
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']); expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
}); });
@@ -76,9 +57,66 @@ describe('UploadComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
spyOn(router, 'navigate'); spyOn(router, 'navigate');
component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } }); component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } });
expect(component.errorMessage).toBeTruthy(); expect(component.errorMessage).toBeTruthy();
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
}); });
// New polish tests
it('submit button is disabled when no file is selected', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
expect(btn.disabled).toBeTrue();
});
it('submit button shows "Uploading…" label while uploading is true', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
component.uploading = true;
fixture.detectChanges();
const btn = (fixture.nativeElement as HTMLElement).querySelector('button[type="submit"]') as HTMLButtonElement;
expect(btn.textContent).toContain('Uploading');
expect(btn.disabled).toBeTrue();
});
it('showSuccess banner is hidden initially', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.success-banner')).toBeNull();
});
it('showSuccess banner is visible when showSuccess is true', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
component.showSuccess = true;
component.uploadedFilename = 'photo.jpg';
fixture.detectChanges();
const banner = (fixture.nativeElement as HTMLElement).querySelector('.success-banner');
expect(banner).not.toBeNull();
expect(banner!.textContent).toContain('photo.jpg');
});
it('shows validation error message for 422 response', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.handleUploadError({ status: 422, error: { detail: 'Unsupported file type', code: 'invalid_mime_type' } });
fixture.detectChanges();
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
expect(err!.textContent).toContain('Unsupported file type');
});
it('shows generic error message for network error', () => {
const fixture = TestBed.createComponent(UploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.handleUploadError({ status: 500, error: null });
fixture.detectChanges();
const err = (fixture.nativeElement as HTMLElement).querySelector('.error');
expect(err!.textContent).toBeTruthy();
});
}); });

View File

@@ -1,13 +1,13 @@
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
@Component({ @Component({
selector: 'app-upload', selector: 'app-upload',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="upload-page"> <div class="upload-page">
@@ -15,45 +15,91 @@ import { ImageRecord, ImageService } from '../services/image.service';
<div <div
class="drop-zone" class="drop-zone"
role="button"
tabindex="0"
[class.drag-over]="isDragOver" [class.drag-over]="isDragOver"
(dragover)="onDragOver($event)" (dragover)="onDragOver($event)"
(dragleave)="isDragOver = false" (dragleave)="isDragOver = false"
(drop)="onDrop($event)" (drop)="onDrop($event)"
(click)="fileInput.click()" (click)="fileInput.click()"
(keydown.enter)="fileInput.click()"
(keydown.space)="fileInput.click()"
> >
<span class="drop-icon">📁</span>
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p> <p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" /> <input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
</div> </div>
<div class="tag-input" *ngIf="selectedFile"> <div class="tag-input" *ngIf="selectedFile">
<label>Tags (comma or space separated)</label> <label for="tag-input">Tags (comma or space separated)</label>
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" /> <input id="tag-input" [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
<div class="chips"> <div class="chips">
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span> <span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
</div> </div>
</div> </div>
<button [disabled]="!selectedFile || uploading" (click)="submit()"> <button type="submit" [disabled]="!selectedFile || uploading" (click)="submit()">
<span *ngIf="uploading" class="spinner"></span>
{{ uploading ? 'Uploading…' : 'Upload' }} {{ uploading ? 'Uploading…' : 'Upload' }}
</button> </button>
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p> <!-- Success banner -->
<div class="success-banner" *ngIf="showSuccess">
<span class="success-icon">✔</span>
<span>{{ uploadedFilename }} uploaded.</span>
<span class="banner-links">
<button type="button" class="link" (click)="resetForm()">Upload another</button>
<a routerLink="/" class="link">View in library</a>
</span>
</div>
<p class="toast" *ngIf="toastMessage && !showSuccess">{{ toastMessage }}</p>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
</div> </div>
`, `,
styles: [` styles: [`
.upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; } .upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; }
.drop-zone { border: 2px dashed #555; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; } h1 { margin-bottom: 24px; }
.drop-zone.drag-over { border-color: #fff; background: #1a1a1a; } .drop-zone {
border: 2px dashed color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: var(--radius);
padding: 48px;
text-align: center;
cursor: pointer;
color: var(--text-muted);
background: var(--surface);
transition: border-color var(--transition), background var(--transition);
}
.drop-zone.drag-over { border-color: var(--accent); background: var(--surface-raised); }
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
.tag-input { margin: 16px 0; } .tag-input { margin: 16px 0; }
label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; } label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: var(--text-muted); }
input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; } input[type=text], input:not([type]) { width: 100%; padding: 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
input:focus { outline: none; border-color: var(--border-focus); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; } .chip { background: var(--surface-raised); padding: 2px 10px; border-radius: var(--radius-chip); font-size: 0.85rem; }
button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; } button[type="submit"] {
button:disabled { opacity: 0.5; cursor: default; } display: flex; align-items: center; gap: 8px;
.toast { color: #4a9eff; margin-top: 12px; } padding: 10px 24px; background: var(--accent); color: var(--accent-text);
.error { color: #ff6b6b; margin-top: 12px; } border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600;
transition: opacity var(--transition);
}
button[type="submit"]:disabled { opacity: 0.5; cursor: default; color: var(--text-muted); }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--accent-text); border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
.success-banner {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-top: 16px; padding: 12px 16px;
background: color-mix(in srgb, #2ecc71 12%, var(--surface));
border: 1px solid color-mix(in srgb, #2ecc71 30%, transparent);
border-radius: var(--radius); color: var(--text);
}
.success-icon { color: #2ecc71; font-size: 1.1rem; }
.banner-links { display: flex; gap: 12px; margin-left: auto; }
.link { color: var(--accent); cursor: pointer; text-decoration: none; font-size: 0.9rem; }
.link:hover { text-decoration: underline; }
.toast { color: var(--accent); margin-top: 12px; }
.error { color: var(--danger); margin-top: 12px; }
`], `],
}) })
export class UploadComponent { export class UploadComponent {
@@ -63,6 +109,9 @@ export class UploadComponent {
toastMessage = ''; toastMessage = '';
errorMessage = ''; errorMessage = '';
isDragOver = false; isDragOver = false;
showSuccess = false;
uploadedFilename = '';
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
constructor( constructor(
private imageService: ImageService, private imageService: ImageService,
@@ -101,6 +150,7 @@ export class UploadComponent {
this.uploading = true; this.uploading = true;
this.errorMessage = ''; this.errorMessage = '';
this.toastMessage = ''; this.toastMessage = '';
this.showSuccess = false;
const tags = this.parseTagInput(this.tagInput); const tags = this.parseTagInput(this.tagInput);
this.imageService.upload(this.selectedFile, tags).subscribe({ this.imageService.upload(this.selectedFile, tags).subscribe({
@@ -121,13 +171,36 @@ export class UploadComponent {
if (res.duplicate) { if (res.duplicate) {
this.toastMessage = 'Already in your library'; this.toastMessage = 'Already in your library';
} else { } else {
this.toastMessage = 'Image uploaded successfully!'; this.uploadedFilename = this.selectedFile?.name ?? res.id;
this.showSuccess = true;
this.cdr.markForCheck();
if (this.dismissTimer) clearTimeout(this.dismissTimer);
this.dismissTimer = setTimeout(() => {
this.showSuccess = false;
this.cdr.markForCheck();
}, 4000);
} }
await this.router.navigate(['/images', res.id]); await this.router.navigate(['/images', res.id]);
} }
handleUploadError(err: any): void { handleUploadError(err: unknown): void {
const apiError = err?.error; const httpErr = err as { status?: number; error?: { detail?: string } };
this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.'; const status = httpErr?.status;
const detail = httpErr?.error?.detail;
if (status === 422 && detail) {
this.errorMessage = detail;
} else {
this.errorMessage = 'Upload failed. Please try again.';
}
}
resetForm(): void {
this.selectedFile = null;
this.tagInput = '';
this.toastMessage = '';
this.errorMessage = '';
this.showSuccess = false;
if (this.dismissTimer) clearTimeout(this.dismissTimer);
this.cdr.markForCheck();
} }
} }

View File

@@ -1,3 +1,32 @@
:root {
--bg: #0f0f0f;
--surface: #1a1a1a;
--surface-raised: #252525;
--border: #333;
--border-focus: #555;
--text: #e0e0e0;
--text-muted: #777;
--accent: #4a9eff;
--accent-text: #000;
--danger: #c0392b;
--danger-text: #fff;
--radius: 6px;
--radius-chip: 12px;
--transition: 200ms ease;
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-raised) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: var(--radius);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -6,7 +35,7 @@
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f; background: var(--bg);
color: #e0e0e0; color: var(--text);
min-height: 100vh; min-height: 100vh;
} }