diff --git a/.gitignore b/.gitignore index aa5f5c4..a9ae62d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/ .DS_Store Thumbs.db +current_pr.md \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index f21e7b9..fa7186a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database import engine, get_db, Base from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR +from validation import validate_profile_fields R = TypeVar("R", bound=BaseModel) @@ -323,3 +324,85 @@ async def get_sum_records( per_page=per_page, total_pages=total_pages, ) + + +# Profile endpoints +class ProfileResponse(BaseModel): + """Response model for profile data.""" + contact_email: str | None + telegram: str | None + signal: str | None + nostr_npub: str | None + + +class ProfileUpdate(BaseModel): + """Request model for updating profile.""" + contact_email: str | None = None + telegram: str | None = None + signal: str | None = None + nostr_npub: str | None = None + + +def require_regular_user(): + """Dependency that requires the user to have the 'regular' role.""" + async def checker( + current_user: User = Depends(get_current_user), + ) -> User: + if ROLE_REGULAR not in current_user.role_names: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Profile access is only available to regular users", + ) + return current_user + return checker + + +@app.get("/api/profile", response_model=ProfileResponse) +async def get_profile( + current_user: User = Depends(require_regular_user()), +): + """Get the current user's profile (contact details).""" + return ProfileResponse( + contact_email=current_user.contact_email, + telegram=current_user.telegram, + signal=current_user.signal, + nostr_npub=current_user.nostr_npub, + ) + + +@app.put("/api/profile", response_model=ProfileResponse) +async def update_profile( + data: ProfileUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_regular_user()), +): + """Update the current user's profile (contact details).""" + # Validate all fields + errors = validate_profile_fields( + contact_email=data.contact_email, + telegram=data.telegram, + signal=data.signal, + nostr_npub=data.nostr_npub, + ) + + if errors: + raise HTTPException( + status_code=422, + detail={"field_errors": errors}, + ) + + # Update fields + current_user.contact_email = data.contact_email + current_user.telegram = data.telegram + current_user.signal = data.signal + current_user.nostr_npub = data.nostr_npub + + await db.commit() + await db.refresh(current_user) + + return ProfileResponse( + contact_email=current_user.contact_email, + telegram=current_user.telegram, + signal=current_user.signal, + nostr_npub=current_user.nostr_npub, + ) diff --git a/backend/models.py b/backend/models.py index 378157a..a08e755 100644 --- a/backend/models.py +++ b/backend/models.py @@ -101,6 +101,12 @@ class User(Base): email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + # Contact details (all optional) + contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + telegram: Mapped[str | None] = mapped_column(String(64), nullable=True) + signal: Mapped[str | None] = mapped_column(String(64), nullable=True) + nostr_npub: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Relationship to roles roles: Mapped[list[Role]] = relationship( "Role", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 381017e..21359ac 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "bcrypt>=4.0.0", "python-jose[cryptography]>=3.3.0", "email-validator>=2.0.0", + "bech32>=1.2.0", ] [dependency-groups] diff --git a/backend/seed.py b/backend/seed.py index 185c2a6..45f7a09 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -67,6 +67,8 @@ async def upsert_user(db: AsyncSession, email: str, password: str, role_names: l async def seed() -> None: async with engine.begin() as conn: + # Drop all tables and recreate to ensure schema is up to date + await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) async with async_session() as db: diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..5a45c56 --- /dev/null +++ b/backend/tests/test_profile.py @@ -0,0 +1,400 @@ +"""Tests for user profile and contact details.""" +import pytest +from sqlalchemy import select + +from models import User, ROLE_REGULAR +from auth import get_password_hash +from tests.helpers import unique_email + +# Valid npub for testing (32 zero bytes encoded as bech32) +VALID_NPUB = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme" + + +class TestUserContactFields: + """Test that contact fields work correctly on the User model.""" + + async def test_contact_fields_default_to_none(self, client_factory): + """New users should have all contact fields as None.""" + email = unique_email("test") + + async with client_factory.get_db_session() as db: + user = User( + email=email, + hashed_password=get_password_hash("password123"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + assert user.contact_email is None + assert user.telegram is None + assert user.signal is None + assert user.nostr_npub is None + + async def test_contact_fields_can_be_set(self, client_factory): + """Contact fields can be set when creating a user.""" + email = unique_email("test") + + async with client_factory.get_db_session() as db: + user = User( + email=email, + hashed_password=get_password_hash("password123"), + contact_email="contact@example.com", + telegram="@alice", + signal="alice.42", + nostr_npub="npub1test", + ) + db.add(user) + await db.commit() + await db.refresh(user) + + assert user.contact_email == "contact@example.com" + assert user.telegram == "@alice" + assert user.signal == "alice.42" + assert user.nostr_npub == "npub1test" + + async def test_contact_fields_persist_after_reload(self, client_factory): + """Contact fields should persist in the database.""" + email = unique_email("test") + + async with client_factory.get_db_session() as db: + user = User( + email=email, + hashed_password=get_password_hash("password123"), + contact_email="contact@example.com", + telegram="@bob", + signal="bob.99", + nostr_npub="npub1xyz", + ) + db.add(user) + await db.commit() + user_id = user.id + + # Reload from database in a new session + async with client_factory.get_db_session() as db: + result = await db.execute(select(User).where(User.id == user_id)) + loaded_user = result.scalar_one() + + assert loaded_user.contact_email == "contact@example.com" + assert loaded_user.telegram == "@bob" + assert loaded_user.signal == "bob.99" + assert loaded_user.nostr_npub == "npub1xyz" + + async def test_contact_fields_can_be_updated(self, client_factory): + """Contact fields can be updated after user creation.""" + email = unique_email("test") + + async with client_factory.get_db_session() as db: + user = User( + email=email, + hashed_password=get_password_hash("password123"), + ) + db.add(user) + await db.commit() + user_id = user.id + + # Update fields + async with client_factory.get_db_session() as db: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one() + + user.contact_email = "new@example.com" + user.telegram = "@updated" + await db.commit() + + # Verify update persisted + async with client_factory.get_db_session() as db: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one() + + assert user.contact_email == "new@example.com" + assert user.telegram == "@updated" + assert user.signal is None # Still None + assert user.nostr_npub is None # Still None + + async def test_contact_fields_can_be_cleared(self, client_factory): + """Contact fields can be set back to None.""" + email = unique_email("test") + + async with client_factory.get_db_session() as db: + user = User( + email=email, + hashed_password=get_password_hash("password123"), + contact_email="contact@example.com", + telegram="@alice", + ) + db.add(user) + await db.commit() + user_id = user.id + + # Clear fields + async with client_factory.get_db_session() as db: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one() + + user.contact_email = None + user.telegram = None + await db.commit() + + # Verify cleared + async with client_factory.get_db_session() as db: + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one() + + assert user.contact_email is None + assert user.telegram is None + + +class TestGetProfileEndpoint: + """Tests for GET /api/profile endpoint.""" + + async def test_regular_user_can_get_profile(self, client_factory, regular_user): + """Regular user can fetch their profile.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/profile") + + assert response.status_code == 200 + data = response.json() + assert "contact_email" in data + assert "telegram" in data + assert "signal" in data + assert "nostr_npub" in data + # All should be None for new user + assert data["contact_email"] is None + assert data["telegram"] is None + assert data["signal"] is None + assert data["nostr_npub"] is None + + async def test_admin_user_cannot_access_profile(self, client_factory, admin_user): + """Admin user gets 403 when trying to access profile.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get("/api/profile") + + assert response.status_code == 403 + assert "regular users" in response.json()["detail"].lower() + + async def test_unauthenticated_user_gets_401(self, client_factory): + """Unauthenticated user gets 401.""" + async with client_factory.create() as client: + response = await client.get("/api/profile") + + assert response.status_code == 401 + + async def test_profile_returns_existing_data(self, client_factory, regular_user): + """Profile returns data that was previously set.""" + # Set some data directly in DB + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + user = result.scalar_one() + user.contact_email = "contact@test.com" + user.telegram = "@testuser" + await db.commit() + + # Fetch via API + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/profile") + + assert response.status_code == 200 + data = response.json() + assert data["contact_email"] == "contact@test.com" + assert data["telegram"] == "@testuser" + assert data["signal"] is None + assert data["nostr_npub"] is None + + +class TestUpdateProfileEndpoint: + """Tests for PUT /api/profile endpoint.""" + + async def test_regular_user_can_update_profile(self, client_factory, regular_user): + """Regular user can update their profile.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={ + "contact_email": "new@example.com", + "telegram": "@newhandle", + "signal": "signal.42", + "nostr_npub": VALID_NPUB, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["contact_email"] == "new@example.com" + assert data["telegram"] == "@newhandle" + assert data["signal"] == "signal.42" + assert data["nostr_npub"] == VALID_NPUB + + async def test_profile_update_persists(self, client_factory, regular_user): + """Profile updates persist in the database.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + await client.put( + "/api/profile", + json={"telegram": "@persisted"}, + ) + + # Fetch again to verify + response = await client.get("/api/profile") + + assert response.status_code == 200 + assert response.json()["telegram"] == "@persisted" + + async def test_admin_user_cannot_update_profile(self, client_factory, admin_user): + """Admin user gets 403 when trying to update profile.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={"telegram": "@admin"}, + ) + + assert response.status_code == 403 + + async def test_unauthenticated_user_gets_401(self, client_factory): + """Unauthenticated user gets 401.""" + async with client_factory.create() as client: + response = await client.put( + "/api/profile", + json={"telegram": "@test"}, + ) + + assert response.status_code == 401 + + async def test_can_clear_fields(self, client_factory, regular_user): + """User can clear fields by setting them to null.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # First set some values + await client.put( + "/api/profile", + json={ + "contact_email": "test@example.com", + "telegram": "@test", + }, + ) + + # Then clear them + response = await client.put( + "/api/profile", + json={ + "contact_email": None, + "telegram": None, + "signal": None, + "nostr_npub": None, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["contact_email"] is None + assert data["telegram"] is None + + async def test_invalid_email_returns_422(self, client_factory, regular_user): + """Invalid email format returns 422 with field error.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={"contact_email": "not-an-email"}, + ) + + assert response.status_code == 422 + data = response.json() + assert "field_errors" in data["detail"] + assert "contact_email" in data["detail"]["field_errors"] + + async def test_invalid_telegram_returns_422(self, client_factory, regular_user): + """Invalid telegram handle returns 422 with field error.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={"telegram": "missing_at_sign"}, + ) + + assert response.status_code == 422 + data = response.json() + assert "field_errors" in data["detail"] + assert "telegram" in data["detail"]["field_errors"] + + async def test_invalid_npub_returns_422(self, client_factory, regular_user): + """Invalid nostr npub returns 422 with field error.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={"nostr_npub": "npub1invalid"}, + ) + + assert response.status_code == 422 + data = response.json() + assert "field_errors" in data["detail"] + assert "nostr_npub" in data["detail"]["field_errors"] + + async def test_multiple_invalid_fields_returns_all_errors(self, client_factory, regular_user): + """Multiple invalid fields return all errors.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/profile", + json={ + "contact_email": "bad-email", + "telegram": "no-at", + }, + ) + + assert response.status_code == 422 + data = response.json() + assert "contact_email" in data["detail"]["field_errors"] + assert "telegram" in data["detail"]["field_errors"] + + async def test_partial_update_preserves_other_fields(self, client_factory, regular_user): + """Updating one field doesn't affect others (they get set to the request values).""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # Set initial values + await client.put( + "/api/profile", + json={ + "contact_email": "initial@example.com", + "telegram": "@initial", + }, + ) + + # Update only telegram, but note: PUT replaces all fields + # So we need to include all fields we want to keep + response = await client.put( + "/api/profile", + json={ + "contact_email": "initial@example.com", + "telegram": "@updated", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["contact_email"] == "initial@example.com" + assert data["telegram"] == "@updated" + + +class TestProfilePrivacy: + """Tests to ensure profile data is private.""" + + async def test_profile_not_in_auth_me(self, client_factory, regular_user): + """Contact details should NOT appear in /api/auth/me response.""" + # First set some profile data + async with client_factory.create(cookies=regular_user["cookies"]) as client: + await client.put( + "/api/profile", + json={ + "contact_email": "secret@example.com", + "telegram": "@secret", + }, + ) + + # Check /api/auth/me doesn't expose it + response = await client.get("/api/auth/me") + + assert response.status_code == 200 + data = response.json() + # These fields should NOT be in the response + assert "contact_email" not in data + assert "telegram" not in data + assert "signal" not in data + assert "nostr_npub" not in data + diff --git a/backend/tests/test_validation.py b/backend/tests/test_validation.py new file mode 100644 index 0000000..3bcebaf --- /dev/null +++ b/backend/tests/test_validation.py @@ -0,0 +1,215 @@ +"""Tests for profile field validation.""" +import pytest + +from validation import ( + validate_contact_email, + validate_telegram, + validate_signal, + validate_nostr_npub, + validate_profile_fields, +) + + +class TestValidateContactEmail: + """Tests for contact email validation.""" + + def test_none_is_valid(self): + assert validate_contact_email(None) is None + + def test_empty_string_is_valid(self): + assert validate_contact_email("") is None + + def test_valid_email(self): + assert validate_contact_email("user@example.com") is None + + def test_valid_email_with_plus(self): + assert validate_contact_email("user+tag@example.com") is None + + def test_valid_email_subdomain(self): + assert validate_contact_email("user@mail.example.com") is None + + def test_invalid_email_no_at(self): + result = validate_contact_email("userexample.com") + assert result is not None + assert "not valid" in result.lower() or "@" in result + + def test_invalid_email_no_domain(self): + result = validate_contact_email("user@") + assert result is not None + + def test_invalid_email_spaces(self): + result = validate_contact_email("user @example.com") + assert result is not None + + +class TestValidateTelegram: + """Tests for Telegram handle validation.""" + + def test_none_is_valid(self): + assert validate_telegram(None) is None + + def test_empty_string_is_valid(self): + assert validate_telegram("") is None + + def test_valid_handle(self): + assert validate_telegram("@alice") is None + + def test_valid_handle_with_numbers(self): + assert validate_telegram("@alice123") is None + + def test_valid_handle_with_underscore(self): + assert validate_telegram("@alice_bob") is None + + def test_valid_handle_min_length(self): + # 5 characters after @ + assert validate_telegram("@abcde") is None + + def test_valid_handle_max_length(self): + # 32 characters after @ + assert validate_telegram("@" + "a" * 32) is None + + def test_missing_at_prefix(self): + result = validate_telegram("alice") + assert result is not None + assert "@" in result + + def test_just_at_sign(self): + result = validate_telegram("@") + assert result is not None + + def test_too_short(self): + # Less than 5 characters after @ + result = validate_telegram("@abcd") + assert result is not None + assert "5" in result + + def test_too_long(self): + # More than 32 characters after @ + result = validate_telegram("@" + "a" * 33) + assert result is not None + assert "32" in result + + def test_starts_with_number(self): + result = validate_telegram("@1alice") + assert result is not None + assert "letter" in result.lower() + + def test_invalid_characters(self): + result = validate_telegram("@alice-bob") + assert result is not None + + +class TestValidateSignal: + """Tests for Signal username validation.""" + + def test_none_is_valid(self): + assert validate_signal(None) is None + + def test_empty_string_is_valid(self): + assert validate_signal("") is None + + def test_valid_username(self): + assert validate_signal("alice.42") is None + + def test_valid_simple_username(self): + assert validate_signal("alice") is None + + def test_valid_with_numbers(self): + assert validate_signal("alice123") is None + + def test_whitespace_only_invalid(self): + result = validate_signal(" ") + assert result is not None + assert "empty" in result.lower() + + def test_too_long(self): + result = validate_signal("a" * 65) + assert result is not None + assert "64" in result + + +class TestValidateNostrNpub: + """Tests for Nostr npub validation.""" + + # Valid npub for testing (32 zero bytes encoded as bech32) + VALID_NPUB = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme" + + def test_none_is_valid(self): + assert validate_nostr_npub(None) is None + + def test_empty_string_is_valid(self): + assert validate_nostr_npub("") is None + + def test_valid_npub(self): + # Generate a valid npub for testing + # This is the npub for 32 zero bytes + assert validate_nostr_npub(self.VALID_NPUB) is None + + def test_wrong_prefix(self): + result = validate_nostr_npub("nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwcv5dz") + assert result is not None + assert "npub" in result.lower() + + def test_invalid_checksum(self): + # Change last character to break checksum + result = validate_nostr_npub("npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsutgpd") + assert result is not None + assert "checksum" in result.lower() + + def test_too_short(self): + result = validate_nostr_npub("npub1short") + assert result is not None + + def test_not_starting_with_npub1(self): + result = validate_nostr_npub("npub2qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsutgpc") + assert result is not None + assert "npub1" in result + + def test_random_string(self): + result = validate_nostr_npub("not_a_valid_npub_at_all") + assert result is not None + + +class TestValidateProfileFields: + """Tests for validating all profile fields together.""" + + def test_all_none_is_valid(self): + errors = validate_profile_fields() + assert errors == {} + + def test_all_valid(self): + errors = validate_profile_fields( + contact_email="user@example.com", + telegram="@alice", + signal="alice.42", + nostr_npub="npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme", + ) + assert errors == {} + + def test_single_invalid_field(self): + errors = validate_profile_fields( + contact_email="user@example.com", + telegram="alice", # Missing @ + signal="alice.42", + ) + assert "telegram" in errors + assert len(errors) == 1 + + def test_multiple_invalid_fields(self): + errors = validate_profile_fields( + contact_email="not-an-email", + telegram="alice", # Missing @ + ) + assert "contact_email" in errors + assert "telegram" in errors + assert len(errors) == 2 + + def test_empty_values_are_valid(self): + errors = validate_profile_fields( + contact_email="", + telegram="", + signal="", + nostr_npub="", + ) + assert errors == {} + diff --git a/backend/validation.py b/backend/validation.py new file mode 100644 index 0000000..e7c5d39 --- /dev/null +++ b/backend/validation.py @@ -0,0 +1,135 @@ +"""Validation utilities for user profile fields.""" +import re +from email_validator import validate_email, EmailNotValidError +from bech32 import bech32_decode + + +def validate_contact_email(value: str | None) -> str | None: + """ + Validate contact email format. + + Returns None if valid, error message if invalid. + Empty/None values are valid (field is optional). + """ + if not value: + return None + + try: + validate_email(value, check_deliverability=False) + return None + except EmailNotValidError as e: + return str(e) + + +def validate_telegram(value: str | None) -> str | None: + """ + Validate Telegram handle. + + Must start with @ if provided. + Returns None if valid, error message if invalid. + Empty/None values are valid (field is optional). + """ + if not value: + return None + + if not value.startswith("@"): + return "Telegram handle must start with @" + + if len(value) < 2: + return "Telegram handle must have at least one character after @" + + # Telegram usernames: 5-32 characters, alphanumeric and underscores + # But we store with @, so check 6-33 total + handle = value[1:] # Remove @ + if len(handle) < 5: + return "Telegram handle must be at least 5 characters (after @)" + + if len(handle) > 32: + return "Telegram handle must be at most 32 characters (after @)" + + if not re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', handle): + return "Telegram handle must start with a letter and contain only letters, numbers, and underscores" + + return None + + +def validate_signal(value: str | None) -> str | None: + """ + Validate Signal username. + + Any non-empty string is valid. + Returns None if valid, error message if invalid. + Empty/None values are valid (field is optional). + """ + if not value: + return None + + # Signal usernames are fairly permissive, just check it's not empty + if len(value.strip()) == 0: + return "Signal username cannot be empty" + + if len(value) > 64: + return "Signal username must be at most 64 characters" + + return None + + +def validate_nostr_npub(value: str | None) -> str | None: + """ + Validate Nostr npub (public key in bech32 format). + + Must be valid bech32 with 'npub' prefix. + Returns None if valid, error message if invalid. + Empty/None values are valid (field is optional). + """ + if not value: + return None + + if not value.startswith("npub1"): + return "Nostr npub must start with 'npub1'" + + # Decode bech32 to validate checksum + hrp, data = bech32_decode(value) + + if hrp is None or data is None: + return "Invalid Nostr npub: bech32 checksum failed" + + if hrp != "npub": + return "Nostr npub must have 'npub' prefix" + + # npub should decode to 32 bytes (256 bits) for a public key + # In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data + if len(data) != 52: + return "Invalid Nostr npub: incorrect length" + + return None + + +def validate_profile_fields( + contact_email: str | None = None, + telegram: str | None = None, + signal: str | None = None, + nostr_npub: str | None = None, +) -> dict[str, str]: + """ + Validate all profile fields at once. + + Returns a dict of field_name -> error_message for any invalid fields. + Empty dict means all fields are valid. + """ + errors: dict[str, str] = {} + + if err := validate_contact_email(contact_email): + errors["contact_email"] = err + + if err := validate_telegram(telegram): + errors["telegram"] = err + + if err := validate_signal(signal): + errors["signal"] = err + + if err := validate_nostr_npub(nostr_npub): + errors["nostr_npub"] = err + + return errors + diff --git a/frontend/app/page.test.tsx b/frontend/app/page.test.tsx index 36babb4..1a3ff04 100644 --- a/frontend/app/page.test.tsx +++ b/frontend/app/page.test.tsx @@ -22,6 +22,9 @@ const mockLogout = vi.fn(); const mockHasPermission = vi.fn((permission: string) => mockUser?.permissions.includes(permission) ?? false ); +const mockHasRole = vi.fn((role: string) => + mockUser?.roles.includes(role) ?? false +); vi.mock("./auth-context", () => ({ useAuth: () => ({ @@ -29,6 +32,7 @@ vi.mock("./auth-context", () => ({ isLoading: mockIsLoading, logout: mockLogout, hasPermission: mockHasPermission, + hasRole: mockHasRole, }), Permission: { VIEW_COUNTER: "view_counter", @@ -51,6 +55,9 @@ beforeEach(() => { mockHasPermission.mockImplementation((permission: string) => mockUser?.permissions.includes(permission) ?? false ); + mockHasRole.mockImplementation((role: string) => + mockUser?.roles.includes(role) ?? false + ); }); afterEach(() => { @@ -217,3 +224,38 @@ describe("Home - Loading State", () => { expect(screen.getByText("Loading...")).toBeDefined(); }); }); + +describe("Home - Navigation", () => { + test("shows My Profile link for regular user", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 0 }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("My Profile")).toBeDefined(); + }); + }); + + test("does not show My Profile link for admin user", async () => { + mockUser = { + id: 1, + email: "admin@example.com", + roles: ["admin"], + permissions: ["view_counter", "view_audit"], + }; + + vi.spyOn(global, "fetch").mockResolvedValue({ + json: () => Promise.resolve({ value: 0 }), + } as Response); + + render(); + + // Wait for render, then check profile link is not present + await waitFor(() => { + expect(screen.getByText("Counter")).toBeDefined(); + }); + expect(screen.queryByText("My Profile")).toBeNull(); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0b71a1f..aecf66d 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,10 +8,11 @@ import { sharedStyles } from "./styles/shared"; export default function Home() { const [count, setCount] = useState(null); - const { user, isLoading, logout, hasPermission } = useAuth(); + const { user, isLoading, logout, hasPermission, hasRole } = useAuth(); const router = useRouter(); const canViewCounter = hasPermission(Permission.VIEW_COUNTER); + const isRegularUser = hasRole("regular"); useEffect(() => { if (!isLoading) { @@ -68,6 +69,12 @@ export default function Home() { Counter Sum + {isRegularUser && ( + <> + + My Profile + + )}
{user.email} diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx new file mode 100644 index 0000000..dd8843f --- /dev/null +++ b/frontend/app/profile/page.test.tsx @@ -0,0 +1,594 @@ +import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; +import { expect, test, vi, beforeEach, afterEach, describe } from "vitest"; +import ProfilePage from "./page"; + +// Mock next/navigation +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Default mock values +let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = { + id: 1, + email: "test@example.com", + roles: ["regular"], + permissions: ["view_counter", "increment_counter", "use_sum"], +}; +let mockIsLoading = false; +const mockLogout = vi.fn(); +const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false); + +vi.mock("../auth-context", () => ({ + useAuth: () => ({ + user: mockUser, + isLoading: mockIsLoading, + logout: mockLogout, + hasRole: mockHasRole, + }), +})); + +// Mock profile data +const mockProfileData = { + contact_email: "contact@example.com", + telegram: "@testuser", + signal: "signal.42", + nostr_npub: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme", +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Reset to authenticated regular user + mockUser = { + id: 1, + email: "test@example.com", + roles: ["regular"], + permissions: ["view_counter", "increment_counter", "use_sum"], + }; + mockIsLoading = false; + mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); +}); + +afterEach(() => { + cleanup(); +}); + +describe("ProfilePage - Display", () => { + test("renders loading state initially", () => { + mockIsLoading = true; + vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); + + render(); + expect(screen.getByText("Loading...")).toBeDefined(); + }); + + test("renders profile page title", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + }); + + test("displays login email as read-only", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + const loginEmailInput = screen.getByDisplayValue("test@example.com"); + expect(loginEmailInput).toBeDefined(); + expect(loginEmailInput).toHaveProperty("disabled", true); + }); + }); + + test("shows read-only badge for login email", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByText("Read only")).toBeDefined(); + }); + }); + + test("shows hint about login email", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByText(/cannot be changed/i)).toBeDefined(); + }); + }); + + test("displays contact details section hint", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByText(/communication purposes only/i)).toBeDefined(); + }); + }); + + test("displays fetched profile data", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByDisplayValue("contact@example.com")).toBeDefined(); + expect(screen.getByDisplayValue("@testuser")).toBeDefined(); + expect(screen.getByDisplayValue("signal.42")).toBeDefined(); + expect(screen.getByDisplayValue(mockProfileData.nostr_npub)).toBeDefined(); + }); + }); + + test("fetches profile with credentials", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:8000/api/profile", + expect.objectContaining({ + credentials: "include", + }) + ); + }); + }); + + test("displays empty fields when profile has null values", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + await waitFor(() => { + // Check that inputs exist with empty values (placeholders shown) + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + expect(telegramInput.value).toBe(""); + }); + }); +}); + +describe("ProfilePage - Navigation", () => { + test("shows nav links for regular user", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + expect(screen.getByText("Counter")).toBeDefined(); + expect(screen.getByText("Sum")).toBeDefined(); + }); + }); + + test("highlights My Profile in nav", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + // My Profile should be visible (as current page indicator) + const navItems = screen.getAllByText("My Profile"); + expect(navItems.length).toBeGreaterThan(0); + }); + }); +}); + +describe("ProfilePage - Access Control", () => { + test("redirects to login when not authenticated", async () => { + mockUser = null; + + render(); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/login"); + }); + }); + + test("redirects admin to audit page", async () => { + mockUser = { + id: 1, + email: "admin@example.com", + roles: ["admin"], + permissions: ["view_audit"], + }; + + render(); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/audit"); + }); + }); + + test("does not fetch profile for admin user", async () => { + mockUser = { + id: 1, + email: "admin@example.com", + roles: ["admin"], + permissions: ["view_audit"], + }; + const fetchSpy = vi.spyOn(global, "fetch"); + + render(); + + // Give it a moment to potentially fetch + await new Promise((r) => setTimeout(r, 100)); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ProfilePage - Loading State", () => { + test("does not redirect while loading", () => { + mockIsLoading = true; + mockUser = null; + + render(); + + expect(mockPush).not.toHaveBeenCalled(); + }); + + test("shows loading indicator while loading", () => { + mockIsLoading = true; + + render(); + + expect(screen.getByText("Loading...")).toBeDefined(); + }); +}); + +describe("ProfilePage - Form Behavior", () => { + test("submit button is disabled when no changes", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + await waitFor(() => { + const submitButton = screen.getByRole("button", { name: /save changes/i }); + expect(submitButton).toHaveProperty("disabled", true); + }); + }); + + test("submit button is enabled after field changes", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockProfileData), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByDisplayValue("@testuser")).toBeDefined(); + }); + + const telegramInput = screen.getByDisplayValue("@testuser"); + fireEvent.change(telegramInput, { target: { value: "@newhandle" } }); + + await waitFor(() => { + const submitButton = screen.getByRole("button", { name: /save changes/i }); + expect(submitButton).toHaveProperty("disabled", false); + }); + }); + + test("shows inline error for invalid telegram handle", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + fireEvent.change(telegramInput, { target: { value: "noatsign" } }); + + await waitFor(() => { + expect(screen.getByText(/must start with @/i)).toBeDefined(); + }); + }); + + test("shows inline error for invalid npub", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + const npubInput = document.getElementById("nostr_npub") as HTMLInputElement; + fireEvent.change(npubInput, { target: { value: "invalidnpub" } }); + + await waitFor(() => { + expect(screen.getByText(/must start with 'npub1'/i)).toBeDefined(); + }); + }); + + test("submit button is disabled when form has validation errors", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + // Enter invalid telegram (no @) + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + fireEvent.change(telegramInput, { target: { value: "noatsign" } }); + + await waitFor(() => { + const submitButton = screen.getByRole("button", { name: /save changes/i }); + expect(submitButton).toHaveProperty("disabled", true); + }); + }); + + test("clears error when field becomes valid", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + + // First enter invalid value + fireEvent.change(telegramInput, { target: { value: "noat" } }); + await waitFor(() => { + expect(screen.getByText(/must start with @/i)).toBeDefined(); + }); + + // Then fix it + fireEvent.change(telegramInput, { target: { value: "@validhandle" } }); + await waitFor(() => { + expect(screen.queryByText(/must start with @/i)).toBeNull(); + }); + }); +}); + +describe("ProfilePage - Form Submission", () => { + test("shows success toast after successful save", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + // Change email + const emailInput = document.getElementById("contact_email") as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: "new@example.com" } }); + + // Submit + const submitButton = screen.getByRole("button", { name: /save changes/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/saved successfully/i)).toBeDefined(); + }); + + // Verify PUT was called + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:8000/api/profile", + expect.objectContaining({ + method: "PUT", + credentials: "include", + }) + ); + }); + + test("shows inline errors from backend validation", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => Promise.resolve({ + detail: { + field_errors: { + telegram: "Backend error: invalid handle", + }, + }, + }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + // Enter a value that passes frontend validation but fails backend + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + fireEvent.change(telegramInput, { target: { value: "@validfrontend" } }); + + // Submit + const submitButton = screen.getByRole("button", { name: /save changes/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/backend error/i)).toBeDefined(); + }); + }); + + test("shows error toast on network failure", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response) + .mockRejectedValueOnce(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + // Change something + const emailInput = document.getElementById("contact_email") as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: "new@example.com" } }); + + // Submit + const submitButton = screen.getByRole("button", { name: /save changes/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeDefined(); + }); + }); + + test("submit button shows 'Saving...' while submitting", async () => { + let resolveSubmit: (value: Response) => void; + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve; + }); + + vi.spyOn(global, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response) + .mockReturnValueOnce(submitPromise as Promise); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); + }); + + // Change something + const emailInput = document.getElementById("contact_email") as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: "new@example.com" } }); + + // Submit + const submitButton = screen.getByRole("button", { name: /save changes/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText("Saving...")).toBeDefined(); + }); + + // Resolve the promise + resolveSubmit!({ + ok: true, + json: () => Promise.resolve({ + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined(); + }); + }); +}); + diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 0000000..68584ce --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,575 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "../auth-context"; +import { API_URL } from "../config"; +import { sharedStyles } from "../styles/shared"; + +interface ProfileData { + contact_email: string | null; + telegram: string | null; + signal: string | null; + nostr_npub: string | null; +} + +interface FormData { + contact_email: string; + telegram: string; + signal: string; + nostr_npub: string; +} + +interface FieldErrors { + contact_email?: string; + telegram?: string; + signal?: string; + nostr_npub?: string; +} + +// Client-side validation matching backend rules +function validateEmail(value: string): string | undefined { + if (!value) return undefined; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(value)) { + return "Please enter a valid email address"; + } + return undefined; +} + +function validateTelegram(value: string): string | undefined { + if (!value) return undefined; + if (!value.startsWith("@")) { + return "Telegram handle must start with @"; + } + const handle = value.slice(1); + if (handle.length < 5) { + return "Telegram handle must be at least 5 characters (after @)"; + } + if (handle.length > 32) { + return "Telegram handle must be at most 32 characters (after @)"; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(handle)) { + return "Telegram handle must start with a letter and contain only letters, numbers, and underscores"; + } + return undefined; +} + +function validateSignal(value: string): string | undefined { + if (!value) return undefined; + if (value.trim().length === 0) { + return "Signal username cannot be empty"; + } + if (value.length > 64) { + return "Signal username must be at most 64 characters"; + } + return undefined; +} + +function validateNostrNpub(value: string): string | undefined { + if (!value) return undefined; + if (!value.startsWith("npub1")) { + return "Nostr npub must start with 'npub1'"; + } + // Basic length check (valid npubs are 63 characters) + if (value.length !== 63) { + return "Invalid Nostr npub format"; + } + // Check for valid bech32 characters + if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(value)) { + return "Invalid Nostr npub: contains invalid characters"; + } + return undefined; +} + +function validateForm(data: FormData): FieldErrors { + const errors: FieldErrors = {}; + const emailError = validateEmail(data.contact_email); + if (emailError) errors.contact_email = emailError; + const telegramError = validateTelegram(data.telegram); + if (telegramError) errors.telegram = telegramError; + const signalError = validateSignal(data.signal); + if (signalError) errors.signal = signalError; + const npubError = validateNostrNpub(data.nostr_npub); + if (npubError) errors.nostr_npub = npubError; + return errors; +} + +export default function ProfilePage() { + const { user, isLoading, logout, hasRole } = useAuth(); + const router = useRouter(); + const [originalData, setOriginalData] = useState(null); + const [formData, setFormData] = useState({ + contact_email: "", + telegram: "", + signal: "", + nostr_npub: "", + }); + const [errors, setErrors] = useState({}); + const [isLoadingProfile, setIsLoadingProfile] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + + const isRegularUser = hasRole("regular"); + + // Check if form has changes + const hasChanges = useCallback(() => { + if (!originalData) return false; + return ( + formData.contact_email !== originalData.contact_email || + formData.telegram !== originalData.telegram || + formData.signal !== originalData.signal || + formData.nostr_npub !== originalData.nostr_npub + ); + }, [formData, originalData]); + + // Check if form is valid + const isValid = useCallback(() => { + return Object.keys(errors).length === 0; + }, [errors]); + + useEffect(() => { + if (!isLoading) { + if (!user) { + router.push("/login"); + } else if (!isRegularUser) { + router.push("/audit"); + } + } + }, [isLoading, user, router, isRegularUser]); + + useEffect(() => { + if (user && isRegularUser) { + fetchProfile(); + } + }, [user, isRegularUser]); + + // Auto-dismiss toast after 3 seconds + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + const fetchProfile = async () => { + try { + const res = await fetch(`${API_URL}/api/profile`, { + credentials: "include", + }); + if (res.ok) { + const data: ProfileData = await res.json(); + const formValues: FormData = { + contact_email: data.contact_email || "", + telegram: data.telegram || "", + signal: data.signal || "", + nostr_npub: data.nostr_npub || "", + }; + setFormData(formValues); + setOriginalData(formValues); + } + } catch { + // Handle error silently for now + } finally { + setIsLoadingProfile(false); + } + }; + + const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent) => { + const value = e.target.value; + setFormData((prev) => ({ ...prev, [field]: value })); + + // Validate on change and clear error if valid + const newFormData = { ...formData, [field]: value }; + const newErrors = validateForm(newFormData); + setErrors(newErrors); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate all fields + const validationErrors = validateForm(formData); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length > 0) { + return; + } + + setIsSubmitting(true); + + try { + const res = await fetch(`${API_URL}/api/profile`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + contact_email: formData.contact_email || null, + telegram: formData.telegram || null, + signal: formData.signal || null, + nostr_npub: formData.nostr_npub || null, + }), + }); + + if (res.ok) { + const data: ProfileData = await res.json(); + const formValues: FormData = { + contact_email: data.contact_email || "", + telegram: data.telegram || "", + signal: data.signal || "", + nostr_npub: data.nostr_npub || "", + }; + setFormData(formValues); + setOriginalData(formValues); + setToast({ message: "Profile saved successfully!", type: "success" }); + } else if (res.status === 422) { + // Handle validation errors from backend + const errorData = await res.json(); + if (errorData.detail?.field_errors) { + setErrors(errorData.detail.field_errors); + } + setToast({ message: "Please fix the errors below", type: "error" }); + } else { + setToast({ message: "Failed to save profile", type: "error" }); + } + } catch { + setToast({ message: "Network error. Please try again.", type: "error" }); + } finally { + setIsSubmitting(false); + } + }; + + const handleLogout = async () => { + await logout(); + router.push("/login"); + }; + + if (isLoading || isLoadingProfile) { + return ( +
+
Loading...
+
+ ); + } + + if (!user || !isRegularUser) { + return null; + } + + const canSubmit = hasChanges() && isValid() && !isSubmitting; + + return ( +
+ {/* Toast notification */} + {toast && ( +
+ {toast.message} +
+ )} + +
+
+ Counter + + Sum + + My Profile +
+
+ {user.email} + +
+
+ +
+
+
+

My Profile

+

Manage your contact information

+
+ +
+ {/* Login email - read only */} +
+ + + + This is your login email and cannot be changed here. + +
+ +
+ +

Contact Details

+

+ These are for communication purposes only — they won't affect your login. +

+ + {/* Contact email */} +
+ + + {errors.contact_email && ( + {errors.contact_email} + )} +
+ + {/* Telegram */} +
+ + + {errors.telegram && ( + {errors.telegram} + )} +
+ + {/* Signal */} +
+ + + {errors.signal && ( + {errors.signal} + )} +
+ + {/* Nostr npub */} +
+ + + {errors.nostr_npub && ( + {errors.nostr_npub} + )} +
+ + + +
+
+
+ ); +} + +const pageStyles: Record = { + profileCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "24px", + padding: "2.5rem", + width: "100%", + maxWidth: "480px", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + cardHeader: { + marginBottom: "2rem", + }, + cardTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "2rem", + fontWeight: 400, + color: "#fff", + margin: 0, + letterSpacing: "-0.02em", + }, + cardSubtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", + }, + form: { + display: "flex", + flexDirection: "column", + gap: "1.25rem", + }, + field: { + display: "flex", + flexDirection: "column", + gap: "0.5rem", + }, + label: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.7)", + fontSize: "0.875rem", + fontWeight: 500, + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + readOnlyBadge: { + fontSize: "0.7rem", + fontWeight: 500, + color: "rgba(255, 255, 255, 0.4)", + background: "rgba(255, 255, 255, 0.08)", + padding: "0.15rem 0.5rem", + borderRadius: "4px", + textTransform: "uppercase", + letterSpacing: "0.05em", + }, + input: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.875rem 1rem", + fontSize: "1rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "12px", + color: "#fff", + outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", + }, + inputReadOnly: { + background: "rgba(255, 255, 255, 0.02)", + color: "rgba(255, 255, 255, 0.5)", + cursor: "not-allowed", + }, + inputError: { + borderColor: "rgba(239, 68, 68, 0.5)", + boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)", + }, + hint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + fontStyle: "italic", + }, + errorText: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + color: "#fca5a5", + }, + divider: { + height: "1px", + background: "rgba(255, 255, 255, 0.08)", + margin: "0.75rem 0", + }, + sectionLabel: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.8)", + margin: 0, + textTransform: "uppercase", + letterSpacing: "0.05em", + }, + sectionHint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.8rem", + color: "rgba(255, 255, 255, 0.4)", + margin: 0, + marginBottom: "0.5rem", + }, + button: { + fontFamily: "'DM Sans', system-ui, sans-serif", + marginTop: "1rem", + padding: "1rem", + fontSize: "1rem", + fontWeight: 600, + background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", + color: "#fff", + border: "none", + borderRadius: "12px", + cursor: "pointer", + transition: "transform 0.2s, box-shadow 0.2s", + boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", + }, + buttonDisabled: { + opacity: 0.5, + cursor: "not-allowed", + boxShadow: "none", + }, + toast: { + position: "fixed", + top: "1.5rem", + right: "1.5rem", + padding: "1rem 1.5rem", + borderRadius: "12px", + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + fontWeight: 500, + zIndex: 1000, + animation: "slideIn 0.3s ease-out", + boxShadow: "0 10px 25px rgba(0, 0, 0, 0.3)", + }, + toastSuccess: { + background: "rgba(34, 197, 94, 0.9)", + color: "#fff", + }, + toastError: { + background: "rgba(239, 68, 68, 0.9)", + color: "#fff", + }, +}; + +const styles = { ...sharedStyles, ...pageStyles }; diff --git a/frontend/app/sum/page.tsx b/frontend/app/sum/page.tsx index d1a39a6..b372ef2 100644 --- a/frontend/app/sum/page.tsx +++ b/frontend/app/sum/page.tsx @@ -12,10 +12,11 @@ export default function SumPage() { const [result, setResult] = useState(null); const [showResult, setShowResult] = useState(false); const [error, setError] = useState(null); - const { user, isLoading, logout, hasPermission } = useAuth(); + const { user, isLoading, logout, hasPermission, hasRole } = useAuth(); const router = useRouter(); const canUseSum = hasPermission(Permission.USE_SUM); + const isRegularUser = hasRole("regular"); useEffect(() => { if (!isLoading) { @@ -82,6 +83,12 @@ export default function SumPage() { Counter Sum + {isRegularUser && ( + <> + + My Profile + + )}
{user.email} diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts new file mode 100644 index 0000000..1f42908 --- /dev/null +++ b/frontend/e2e/profile.spec.ts @@ -0,0 +1,345 @@ +import { test, expect, Page } from "@playwright/test"; + +/** + * Profile E2E tests + * + * These tests verify that: + * 1. Regular users can access and use the profile page + * 2. Admin users cannot access the profile page + * 3. Profile data persists correctly + * 4. Validation works as expected + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +// Test credentials - must match what's seeded in the database via seed.py +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Required environment variable ${name} is not set.`); + } + return value; +} + +const REGULAR_USER = { + email: getRequiredEnv("DEV_USER_EMAIL"), + password: getRequiredEnv("DEV_USER_PASSWORD"), +}; + +const ADMIN_USER = { + email: getRequiredEnv("DEV_ADMIN_EMAIL"), + password: getRequiredEnv("DEV_ADMIN_PASSWORD"), +}; + +// Valid test npub (32 zero bytes encoded as bech32) +const VALID_NPUB = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme"; + +// Helper to clear auth cookies +async function clearAuth(page: Page) { + await page.context().clearCookies(); +} + +// Helper to login a user +async function loginUser(page: Page, email: string, password: string) { + await page.goto("/login"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }); +} + +// Helper to clear profile data via API +async function clearProfileData(page: Page) { + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === "auth_token"); + + if (authCookie) { + await page.request.put(`${API_URL}/api/profile`, { + headers: { + Cookie: `auth_token=${authCookie.value}`, + "Content-Type": "application/json", + }, + data: { + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }, + }); + } +} + +test.describe("Profile - Regular User Access", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + }); + + test("can navigate to profile page from counter", async ({ page }) => { + await page.goto("/"); + + // Should see My Profile link + await expect(page.getByText("My Profile")).toBeVisible(); + + // Click to navigate + await page.click('a[href="/profile"]'); + await expect(page).toHaveURL("/profile"); + }); + + test("can navigate to profile page from sum", async ({ page }) => { + await page.goto("/sum"); + + // Should see My Profile link + await expect(page.getByText("My Profile")).toBeVisible(); + + // Click to navigate + await page.click('a[href="/profile"]'); + await expect(page).toHaveURL("/profile"); + }); + + test("profile page displays correct elements", async ({ page }) => { + await page.goto("/profile"); + + // Should see page title + await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible(); + + // Should see login email label with read-only badge + await expect(page.getByText("Login EmailRead only")).toBeVisible(); + + // Should see contact details section + await expect(page.getByText("Contact Details")).toBeVisible(); + await expect(page.getByText(/communication purposes only/i)).toBeVisible(); + + // Should see all form fields + await expect(page.getByLabel("Contact Email")).toBeVisible(); + await expect(page.getByLabel("Telegram")).toBeVisible(); + await expect(page.getByLabel("Signal")).toBeVisible(); + await expect(page.getByLabel("Nostr (npub)")).toBeVisible(); + }); + + test("login email is displayed and read-only", async ({ page }) => { + await page.goto("/profile"); + + // Login email should show the user's email + const loginEmailInput = page.locator('input[type="email"][disabled]'); + await expect(loginEmailInput).toHaveValue(REGULAR_USER.email); + await expect(loginEmailInput).toBeDisabled(); + }); + + test("navigation shows Counter, Sum, and My Profile", async ({ page }) => { + await page.goto("/profile"); + + // Should see all nav items (Counter and Sum as links) + await expect(page.locator('a[href="/"]')).toBeVisible(); + await expect(page.locator('a[href="/sum"]')).toBeVisible(); + // My Profile is the page title (h1) since we're on this page + await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible(); + }); +}); + +test.describe("Profile - Form Behavior", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + // Clear any existing profile data + await clearProfileData(page); + }); + + test("new user has empty profile fields", async ({ page }) => { + await page.goto("/profile"); + + // All editable fields should be empty + await expect(page.getByLabel("Contact Email")).toHaveValue(""); + await expect(page.getByLabel("Telegram")).toHaveValue(""); + await expect(page.getByLabel("Signal")).toHaveValue(""); + await expect(page.getByLabel("Nostr (npub)")).toHaveValue(""); + }); + + test("save button is disabled when no changes", async ({ page }) => { + await page.goto("/profile"); + + // Save button should be disabled + const saveButton = page.getByRole("button", { name: /save changes/i }); + await expect(saveButton).toBeDisabled(); + }); + + test("save button is enabled after making changes", async ({ page }) => { + await page.goto("/profile"); + + // Make a change + await page.fill("#telegram", "@testhandle"); + + // Save button should be enabled + const saveButton = page.getByRole("button", { name: /save changes/i }); + await expect(saveButton).toBeEnabled(); + }); + + test("can save profile and values persist", async ({ page }) => { + await page.goto("/profile"); + + // Fill in all fields + await page.fill("#contact_email", "contact@test.com"); + await page.fill("#telegram", "@testuser"); + await page.fill("#signal", "signal.42"); + await page.fill("#nostr_npub", VALID_NPUB); + + // Save + await page.click('button:has-text("Save Changes")'); + + // Should see success message + await expect(page.getByText(/saved successfully/i)).toBeVisible(); + + // Reload and verify values persist + await page.reload(); + + await expect(page.getByLabel("Contact Email")).toHaveValue("contact@test.com"); + await expect(page.getByLabel("Telegram")).toHaveValue("@testuser"); + await expect(page.getByLabel("Signal")).toHaveValue("signal.42"); + await expect(page.getByLabel("Nostr (npub)")).toHaveValue(VALID_NPUB); + }); + + test("can clear a field and save", async ({ page }) => { + await page.goto("/profile"); + + // First set a value + await page.fill("#telegram", "@initial"); + await page.click('button:has-text("Save Changes")'); + await expect(page.getByText(/saved successfully/i)).toBeVisible(); + + // Wait for toast to disappear + await page.waitForTimeout(3500); + + // Clear the field + await page.fill("#telegram", ""); + await page.click('button:has-text("Save Changes")'); + await expect(page.getByText(/saved successfully/i)).toBeVisible(); + + // Reload and verify it's cleared + await page.reload(); + await expect(page.getByLabel("Telegram")).toHaveValue(""); + }); +}); + +test.describe("Profile - Validation", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + await clearProfileData(page); + }); + + test("shows error for invalid telegram handle (missing @)", async ({ page }) => { + await page.goto("/profile"); + + // Enter invalid telegram (no @) + await page.fill("#telegram", "noatsign"); + + // Should show error + await expect(page.getByText(/must start with @/i)).toBeVisible(); + + // Save button should be disabled + const saveButton = page.getByRole("button", { name: /save changes/i }); + await expect(saveButton).toBeDisabled(); + }); + + test("shows error for invalid npub", async ({ page }) => { + await page.goto("/profile"); + + // Enter invalid npub + await page.fill("#nostr_npub", "invalidnpub"); + + // Should show error + await expect(page.getByText(/must start with 'npub1'/i)).toBeVisible(); + + // Save button should be disabled + const saveButton = page.getByRole("button", { name: /save changes/i }); + await expect(saveButton).toBeDisabled(); + }); + + test("can fix validation error and save", async ({ page }) => { + await page.goto("/profile"); + + // Enter invalid telegram + await page.fill("#telegram", "noat"); + await expect(page.getByText(/must start with @/i)).toBeVisible(); + + // Fix it + await page.fill("#telegram", "@validhandle"); + + // Error should disappear + await expect(page.getByText(/must start with @/i)).not.toBeVisible(); + + // Should be able to save + const saveButton = page.getByRole("button", { name: /save changes/i }); + await expect(saveButton).toBeEnabled(); + + await page.click('button:has-text("Save Changes")'); + await expect(page.getByText(/saved successfully/i)).toBeVisible(); + }); + + test("shows error for invalid email format", async ({ page }) => { + await page.goto("/profile"); + + // Enter invalid email + await page.fill("#contact_email", "not-an-email"); + + // Should show error + await expect(page.getByText(/valid email/i)).toBeVisible(); + }); +}); + +test.describe("Profile - Admin User Access", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); + }); + + test("admin does not see My Profile link", async ({ page }) => { + await page.goto("/audit"); + + // Should be on audit page + await expect(page).toHaveURL("/audit"); + + // Should NOT see My Profile link + await expect(page.locator('a[href="/profile"]')).toHaveCount(0); + }); + + test("admin cannot access profile page - redirected to audit", async ({ page }) => { + await page.goto("/profile"); + + // Should be redirected to audit + await expect(page).toHaveURL("/audit"); + }); + + test("admin API call to profile returns 403", async ({ page, request }) => { + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === "auth_token"); + + if (authCookie) { + // Try to call profile API directly + const response = await request.get(`${API_URL}/api/profile`, { + headers: { + Cookie: `auth_token=${authCookie.value}`, + }, + }); + + expect(response.status()).toBe(403); + } + }); +}); + +test.describe("Profile - Unauthenticated Access", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + }); + + test("profile page redirects to login", async ({ page }) => { + await page.goto("/profile"); + await expect(page).toHaveURL("/login"); + }); + + test("profile API requires authentication", async ({ page, request }) => { + const response = await request.get(`${API_URL}/api/profile`); + expect(response.status()).toBe(401); + }); +}); + diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json index cbcc1fb..1b3e417 100644 --- a/frontend/test-results/.last-run.json +++ b/frontend/test-results/.last-run.json @@ -1,4 +1,6 @@ { - "status": "passed", - "failedTests": [] + "status": "failed", + "failedTests": [ + "d748ac400d08b85935ef-f4350b37502bdc286d29" + ] } \ No newline at end of file diff --git a/frontend/test-results/auth-Session-Persistence-auth-cookie-is-cleared-on-logout/error-context.md b/frontend/test-results/auth-Session-Persistence-auth-cookie-is-cleared-on-logout/error-context.md new file mode 100644 index 0000000..4710b7a --- /dev/null +++ b/frontend/test-results/auth-Session-Persistence-auth-cookie-is-cleared-on-logout/error-context.md @@ -0,0 +1,32 @@ +# Page snapshot + +```yaml +- generic [ref=e1]: + - status [ref=e2]: + - generic [ref=e3]: + - img [ref=e5] + - generic [ref=e7]: + - text: Static route + - button "Hide static indicator" [ref=e8] [cursor=pointer]: + - img [ref=e9] + - alert [ref=e12]: ... + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - generic [ref=e16]: Counter + - generic [ref=e17]: • + - link "Sum" [ref=e18] [cursor=pointer]: + - /url: /sum + - generic [ref=e19]: • + - link "My Profile" [ref=e20] [cursor=pointer]: + - /url: /profile + - generic [ref=e21]: + - generic [ref=e22]: test-1766135485217-ds0t5j@example.com + - button "Sign out" [active] [ref=e23] [cursor=pointer] + - generic [ref=e25]: + - generic [ref=e26]: Current Count + - heading "9" [level=1] [ref=e27] + - button "+ Increment" [ref=e28] [cursor=pointer]: + - generic [ref=e29]: + + - text: Increment +``` \ No newline at end of file