implemented

This commit is contained in:
counterweight 2025-12-19 10:12:55 +01:00
parent 40ca82bb45
commit 409e0df9a6
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
16 changed files with 2451 additions and 4 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ node_modules/
.DS_Store
Thumbs.db
current_pr.md

View file

@ -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,
)

View file

@ -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",

View file

@ -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]

View file

@ -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:

View file

@ -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

View file

@ -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 == {}

135
backend/validation.py Normal file
View file

@ -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

View file

@ -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(<Home />);
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(<Home />);
// Wait for render, then check profile link is not present
await waitFor(() => {
expect(screen.getByText("Counter")).toBeDefined();
});
expect(screen.queryByText("My Profile")).toBeNull();
});
});

View file

@ -8,10 +8,11 @@ import { sharedStyles } from "./styles/shared";
export default function Home() {
const [count, setCount] = useState<number | null>(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() {
<span style={styles.navCurrent}>Counter</span>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
{isRegularUser && (
<>
<span style={styles.navDivider}></span>
<a href="/profile" style={styles.navLink}>My Profile</a>
</>
)}
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>

View file

@ -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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
// 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(<ProfilePage />);
expect(mockPush).not.toHaveBeenCalled();
});
test("shows loading indicator while loading", () => {
mockIsLoading = true;
render(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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<Response>((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<Response>);
render(<ProfilePage />);
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();
});
});
});

View file

@ -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<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({
contact_email: "",
telegram: "",
signal: "",
nostr_npub: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
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<HTMLInputElement>) => {
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 (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isRegularUser) {
return null;
}
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={styles.main}>
{/* Toast notification */}
{toast && (
<div
style={{
...styles.toast,
...(toast.type === "success" ? styles.toastSuccess : styles.toastError),
}}
>
{toast.message}
</div>
)}
<div style={styles.header}>
<div style={styles.nav}>
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>My Profile</span>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}>
<div style={styles.profileCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Profile</h1>
<p style={styles.cardSubtitle}>Manage your contact information</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
{/* Login email - read only */}
<div style={styles.field}>
<label style={styles.label}>
Login Email
<span style={styles.readOnlyBadge}>Read only</span>
</label>
<input
type="email"
value={user.email}
style={{ ...styles.input, ...styles.inputReadOnly }}
disabled
/>
<span style={styles.hint}>
This is your login email and cannot be changed here.
</span>
</div>
<div style={styles.divider} />
<p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}>
These are for communication purposes only they won&apos;t affect your login.
</p>
{/* Contact email */}
<div style={styles.field}>
<label htmlFor="contact_email" style={styles.label}>
Contact Email
</label>
<input
id="contact_email"
type="email"
value={formData.contact_email}
onChange={handleInputChange("contact_email")}
style={{
...styles.input,
...(errors.contact_email ? styles.inputError : {}),
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={styles.errorText}>{errors.contact_email}</span>
)}
</div>
{/* Telegram */}
<div style={styles.field}>
<label htmlFor="telegram" style={styles.label}>
Telegram
</label>
<input
id="telegram"
type="text"
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...styles.input,
...(errors.telegram ? styles.inputError : {}),
}}
placeholder="@username"
/>
{errors.telegram && (
<span style={styles.errorText}>{errors.telegram}</span>
)}
</div>
{/* Signal */}
<div style={styles.field}>
<label htmlFor="signal" style={styles.label}>
Signal
</label>
<input
id="signal"
type="text"
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...styles.input,
...(errors.signal ? styles.inputError : {}),
}}
placeholder="username.01"
/>
{errors.signal && (
<span style={styles.errorText}>{errors.signal}</span>
)}
</div>
{/* Nostr npub */}
<div style={styles.field}>
<label htmlFor="nostr_npub" style={styles.label}>
Nostr (npub)
</label>
<input
id="nostr_npub"
type="text"
value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")}
style={{
...styles.input,
...(errors.nostr_npub ? styles.inputError : {}),
}}
placeholder="npub1..."
/>
{errors.nostr_npub && (
<span style={styles.errorText}>{errors.nostr_npub}</span>
)}
</div>
<button
type="submit"
style={{
...styles.button,
...(!canSubmit ? styles.buttonDisabled : {}),
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
</form>
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
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 };

View file

@ -12,10 +12,11 @@ export default function SumPage() {
const [result, setResult] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false);
const [error, setError] = useState<string | null>(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() {
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>Sum</span>
{isRegularUser && (
<>
<span style={styles.navDivider}></span>
<a href="/profile" style={styles.navLink}>My Profile</a>
</>
)}
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>

View file

@ -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);
});
});

View file

@ -1,4 +1,6 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"d748ac400d08b85935ef-f4350b37502bdc286d29"
]
}

View file

@ -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
```