implemented
This commit is contained in:
parent
40ca82bb45
commit
409e0df9a6
16 changed files with 2451 additions and 4 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ node_modules/
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
current_pr.md
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
400
backend/tests/test_profile.py
Normal file
400
backend/tests/test_profile.py
Normal 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
|
||||
|
||||
215
backend/tests/test_validation.py
Normal file
215
backend/tests/test_validation.py
Normal 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
135
backend/validation.py
Normal 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
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
594
frontend/app/profile/page.test.tsx
Normal file
594
frontend/app/profile/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
575
frontend/app/profile/page.tsx
Normal file
575
frontend/app/profile/page.tsx
Normal 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'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 };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
345
frontend/e2e/profile.spec.ts
Normal file
345
frontend/e2e/profile.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"d748ac400d08b85935ef-f4350b37502bdc286d29"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue