first implementation

This commit is contained in:
counterweight 2025-12-20 11:12:11 +01:00
parent 79458bcba4
commit 870804e7b9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
24 changed files with 5485 additions and 184 deletions

3
.gitignore vendored
View file

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

75
MANUAL_TEST_INVITES.md Normal file
View file

@ -0,0 +1,75 @@
# Invite System - Manual Testing Guide
## Prerequisites
- `make dev` running
- Seeded users: `admin@example.com` / `admin123`, `user@example.com` / `user123`
---
## 1. Admin: Create Invites
1. Login as **admin@example.com**
2. Go to `/admin/invites`
3. ✅ Dropdown shows list of users (not a number input)
4. Select `user@example.com` → Click **Create Invite**
5. ✅ New invite appears with code like `apple-banana-42`, status "ready"
## 2. Admin: Revoke Invite
1. On `/admin/invites`, click **Revoke** on a ready invite
2. ✅ Status changes to "revoked"
## 3. Admin: Filter Invites
1. Use status dropdown to filter by "ready", "spent", "revoked"
2. ✅ Table updates accordingly
---
## 4. Signup with Invite Code
1. **Logout** → Go to `/signup`
2. Enter a valid invite code → Click **Continue**
3. ✅ Form shows "Create account" with email/password fields
4. Fill in new email + password → Submit
5. ✅ Redirected to home, logged in
## 5. Signup via Direct Link
1. Copy a ready invite code (e.g., `apple-banana-42`)
2. Go to `/signup/apple-banana-42`
3. ✅ Redirects to `/signup?code=...` with code pre-validated
4. Complete registration
5. ✅ Works same as manual entry
## 6. Signup Error Cases
1. **Invalid code**: Enter `fake-code-99` → ✅ Shows "not found" error
2. **Spent invite**: Use an already-used code → ✅ Shows error
3. **Revoked invite**: Use a revoked code → ✅ Shows error
---
## 7. Regular User: My Invites
1. Login as the **newly created user** (or `user@example.com` if they have invites)
2. Go to `/invites`
3. ✅ Shows list of invites assigned to this user
4. ✅ Spent invites show who used them
## 8. Profile: Godfather Display
1. Login as user created via invite
2. Go to `/profile`
3. ✅ "Invited By" shows the godfather's email (read-only)
---
## Quick Smoke Test (2 min)
1. Admin login → `/admin/invites` → Create invite for `user@example.com`
2. Copy invite code
3. Logout → `/signup` → Enter code → Register new user
4. Check `/profile` shows godfather
5. Admin: verify invite status is "spent" with used_by email

60
backend/invite_utils.py Normal file
View file

@ -0,0 +1,60 @@
"""Utilities for invite code generation and validation."""
import random
from pathlib import Path
# Load BIP39 words from file
_WORDS_FILE = Path(__file__).parent / "words.txt"
with open(_WORDS_FILE) as f:
BIP39_WORDS = [line.strip() for line in f if line.strip()]
assert len(BIP39_WORDS) == 2048, f"Expected 2048 BIP39 words, got {len(BIP39_WORDS)}"
def generate_invite_identifier() -> str:
"""
Generate a unique invite identifier.
Format: word1-word2-NN where:
- word1, word2 are random BIP39 words
- NN is a two-digit number (00-99)
Returns lowercase identifier.
"""
word1 = random.choice(BIP39_WORDS)
word2 = random.choice(BIP39_WORDS)
number = random.randint(0, 99)
return f"{word1}-{word2}-{number:02d}"
def normalize_identifier(identifier: str) -> str:
"""
Normalize an invite identifier for comparison/lookup.
- Converts to lowercase
- Strips whitespace
"""
return identifier.strip().lower()
def is_valid_identifier_format(identifier: str) -> bool:
"""
Check if an identifier has valid format (word-word-NN).
Does NOT check if words are valid BIP39 words.
"""
parts = identifier.split("-")
if len(parts) != 3:
return False
word1, word2, number = parts
# Check words are non-empty
if not word1 or not word2:
return False
# Check number is two digits
if len(number) != 2 or not number.isdigit():
return False
return True

View file

@ -1,10 +1,10 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, UTC
from typing import Callable, Generic, TypeVar from typing import Callable, Generic, TypeVar
from fastapi import FastAPI, Depends, HTTPException, Response, status, Query from fastapi import FastAPI, Depends, HTTPException, Response, status, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel, EmailStr
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -23,8 +23,9 @@ from auth import (
build_user_response, build_user_response,
) )
from database import engine, get_db, Base from database import engine, get_db, Base
from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR, Invite, InviteStatus
from validation import validate_profile_fields from validation import validate_profile_fields
from invite_utils import generate_invite_identifier, normalize_identifier
R = TypeVar("R", bound=BaseModel) R = TypeVar("R", bound=BaseModel)
@ -98,13 +99,88 @@ async def get_default_role(db: AsyncSession) -> Role | None:
return result.scalar_one_or_none() return result.scalar_one_or_none()
# Invite check endpoint (public)
class InviteCheckResponse(BaseModel):
"""Response for invite check endpoint."""
valid: bool
status: str | None = None
error: str | None = None
@app.get("/api/invites/{identifier}/check", response_model=InviteCheckResponse)
async def check_invite(
identifier: str,
db: AsyncSession = Depends(get_db),
):
"""Check if an invite is valid and can be used for signup."""
normalized = normalize_identifier(identifier)
result = await db.execute(
select(Invite).where(Invite.identifier == normalized)
)
invite = result.scalar_one_or_none()
if not invite:
return InviteCheckResponse(valid=False, error="Invite not found")
if invite.status == InviteStatus.SPENT:
return InviteCheckResponse(
valid=False,
status=invite.status.value,
error="This invite has already been used"
)
if invite.status == InviteStatus.REVOKED:
return InviteCheckResponse(
valid=False,
status=invite.status.value,
error="This invite has been revoked"
)
return InviteCheckResponse(valid=True, status=invite.status.value)
# Auth endpoints # Auth endpoints
class RegisterWithInvite(BaseModel):
"""Request model for registration with invite."""
email: EmailStr
password: str
invite_identifier: str
@app.post("/api/auth/register", response_model=UserResponse) @app.post("/api/auth/register", response_model=UserResponse)
async def register( async def register(
user_data: UserCreate, user_data: RegisterWithInvite,
response: Response, response: Response,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Register a new user using an invite code."""
# Validate invite
normalized_identifier = normalize_identifier(user_data.invite_identifier)
result = await db.execute(
select(Invite).where(Invite.identifier == normalized_identifier)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code",
)
if invite.status == InviteStatus.SPENT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This invite has already been used",
)
if invite.status == InviteStatus.REVOKED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This invite has been revoked",
)
# Check email not already taken
existing_user = await get_user_by_email(db, user_data.email) existing_user = await get_user_by_email(db, user_data.email)
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
@ -112,17 +188,26 @@ async def register(
detail="Email already registered", detail="Email already registered",
) )
# Create user with godfather
user = User( user = User(
email=user_data.email, email=user_data.email,
hashed_password=get_password_hash(user_data.password), hashed_password=get_password_hash(user_data.password),
godfather_id=invite.godfather_id,
) )
# Assign default role if it exists # Assign default role
default_role = await get_default_role(db) default_role = await get_default_role(db)
if default_role: if default_role:
user.roles.append(default_role) user.roles.append(default_role)
db.add(user) db.add(user)
await db.flush() # Get user ID
# Mark invite as spent
invite.status = InviteStatus.SPENT
invite.used_by_id = user.id
invite.spent_at = datetime.now(UTC)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
@ -333,6 +418,7 @@ class ProfileResponse(BaseModel):
telegram: str | None telegram: str | None
signal: str | None signal: str | None
nostr_npub: str | None nostr_npub: str | None
godfather_email: str | None = None
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
@ -358,13 +444,22 @@ async def require_regular_user(
@app.get("/api/profile", response_model=ProfileResponse) @app.get("/api/profile", response_model=ProfileResponse)
async def get_profile( async def get_profile(
current_user: User = Depends(require_regular_user), current_user: User = Depends(require_regular_user),
db: AsyncSession = Depends(get_db),
): ):
"""Get the current user's profile (contact details).""" """Get the current user's profile (contact details and godfather)."""
godfather_email = None
if current_user.godfather_id:
result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = result.scalar_one_or_none()
return ProfileResponse( return ProfileResponse(
contact_email=current_user.contact_email, contact_email=current_user.contact_email,
telegram=current_user.telegram, telegram=current_user.telegram,
signal=current_user.signal, signal=current_user.signal,
nostr_npub=current_user.nostr_npub, nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
) )
@ -398,9 +493,274 @@ async def update_profile(
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
# Get godfather email if set
godfather_email = None
if current_user.godfather_id:
gf_result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = gf_result.scalar_one_or_none()
return ProfileResponse( return ProfileResponse(
contact_email=current_user.contact_email, contact_email=current_user.contact_email,
telegram=current_user.telegram, telegram=current_user.telegram,
signal=current_user.signal, signal=current_user.signal,
nostr_npub=current_user.nostr_npub, nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
# Invite endpoints
class InviteCreate(BaseModel):
"""Request model for creating an invite."""
godfather_id: int
class InviteResponse(BaseModel):
"""Response model for invite data."""
id: int
identifier: str
godfather_id: int
godfather_email: str
status: str
used_by_id: int | None
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
revoked_at: datetime | None
@app.post("/api/admin/invites", response_model=InviteResponse)
async def create_invite(
data: InviteCreate,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""Create a new invite for a specified godfather user."""
# Validate godfather exists
result = await db.execute(select(User).where(User.id == data.godfather_id))
godfather = result.scalar_one_or_none()
if not godfather:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Godfather user not found",
)
# Generate unique identifier
identifier = generate_invite_identifier()
# Create invite
invite = Invite(
identifier=identifier,
godfather_id=godfather.id,
status=InviteStatus.READY,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
)
class UserInviteResponse(BaseModel):
"""Response model for a user's invite (simpler than admin view)."""
id: int
identifier: str
status: str
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
@app.get("/api/invites", response_model=list[UserInviteResponse])
async def get_my_invites(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
):
"""Get all invites owned by the current user."""
result = await db.execute(
select(Invite)
.where(Invite.godfather_id == current_user.id)
.order_by(desc(Invite.created_at))
)
invites = result.scalars().all()
responses = []
for invite in invites:
used_by_email = None
if invite.used_by_id:
# Fetch the user who used this invite
user_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = user_result.scalar_one_or_none()
responses.append(UserInviteResponse(
id=invite.id,
identifier=invite.identifier,
status=invite.status.value,
used_by_email=used_by_email,
created_at=invite.created_at,
spent_at=invite.spent_at,
))
return responses
# Admin Invite Management
PaginatedInviteRecords = PaginatedResponse[InviteResponse]
class AdminUserResponse(BaseModel):
"""Minimal user info for admin dropdowns."""
id: int
email: str
@app.get("/api/admin/users", response_model=list[AdminUserResponse])
async def list_users_for_admin(
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""List all users for admin dropdowns (invite creation, etc.)."""
result = await db.execute(select(User.id, User.email).order_by(User.email))
users = result.all()
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
@app.get("/api/admin/invites", response_model=PaginatedInviteRecords)
async def list_all_invites(
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
status_filter: str | None = Query(None, alias="status", description="Filter by status: ready, spent, revoked"),
godfather_id: int | None = Query(None, description="Filter by godfather user ID"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""List all invites with optional filtering and pagination."""
# Build query
query = select(Invite)
count_query = select(func.count(Invite.id))
# Apply filters
if status_filter:
try:
status_enum = InviteStatus(status_filter)
query = query.where(Invite.status == status_enum)
count_query = count_query.where(Invite.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status_filter}. Must be ready, spent, or revoked",
)
if godfather_id:
query = query.where(Invite.godfather_id == godfather_id)
count_query = count_query.where(Invite.godfather_id == godfather_id)
# Get total count
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
# Get paginated invites
offset = (page - 1) * per_page
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
invites = result.scalars().all()
# Build responses with user emails
records = []
for invite in invites:
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
# Get used_by email if applicable
used_by_email = None
if invite.used_by_id:
ub_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = ub_result.scalar_one_or_none()
records.append(InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=used_by_email,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
))
return PaginatedInviteRecords(
records=records,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages,
)
@app.post("/api/admin/invites/{invite_id}/revoke", response_model=InviteResponse)
async def revoke_invite(
invite_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""Revoke an invite. Only READY invites can be revoked."""
result = await db.execute(select(Invite).where(Invite.id == invite_id))
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite not found",
)
if invite.status != InviteStatus.READY:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot revoke invite with status '{invite.status.value}'. Only READY invites can be revoked.",
)
invite.status = InviteStatus.REVOKED
invite.revoked_at = datetime.now(UTC)
await db.commit()
await db.refresh(invite)
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
) )

View file

@ -23,6 +23,17 @@ class Permission(str, PyEnum):
# Audit permissions # Audit permissions
VIEW_AUDIT = "view_audit" VIEW_AUDIT = "view_audit"
# Invite permissions
MANAGE_INVITES = "manage_invites"
VIEW_OWN_INVITES = "view_own_invites"
class InviteStatus(str, PyEnum):
"""Status of an invite."""
READY = "ready"
SPENT = "spent"
REVOKED = "revoked"
# Role name constants # Role name constants
@ -32,17 +43,19 @@ ROLE_REGULAR = "regular"
# Role definitions with their permissions # Role definitions with their permissions
ROLE_DEFINITIONS: dict[str, RoleConfig] = { ROLE_DEFINITIONS: dict[str, RoleConfig] = {
ROLE_ADMIN: { ROLE_ADMIN: {
"description": "Administrator with audit access", "description": "Administrator with audit and invite management access",
"permissions": [ "permissions": [
Permission.VIEW_AUDIT, Permission.VIEW_AUDIT,
Permission.MANAGE_INVITES,
], ],
}, },
ROLE_REGULAR: { ROLE_REGULAR: {
"description": "Regular user with counter and sum access", "description": "Regular user with counter, sum, and invite access",
"permissions": [ "permissions": [
Permission.VIEW_COUNTER, Permission.VIEW_COUNTER,
Permission.INCREMENT_COUNTER, Permission.INCREMENT_COUNTER,
Permission.USE_SUM, Permission.USE_SUM,
Permission.VIEW_OWN_INVITES,
], ],
}, },
} }
@ -107,6 +120,16 @@ class User(Base):
signal: 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(63), nullable=True) nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
# Godfather (who invited this user) - null for seeded/admin users
godfather_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
godfather: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[godfather_id],
)
# Relationship to roles # Relationship to roles
roles: Mapped[list[Role]] = relationship( roles: Mapped[list[Role]] = relationship(
"Role", "Role",
@ -165,3 +188,40 @@ class CounterRecord(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC) DateTime(timezone=True), default=lambda: datetime.now(UTC)
) )
class Invite(Base):
__tablename__ = "invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
)
# Godfather - the user who owns this invite
godfather_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
godfather: Mapped[User] = relationship(
"User",
foreign_keys=[godfather_id],
lazy="selectin",
)
# User who used this invite (null until spent)
used_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
used_by: Mapped[User | None] = relationship(
"User",
foreign_keys=[used_by_id],
lazy="selectin",
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -1,7 +1,51 @@
import uuid import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Invite, InviteStatus, ROLE_ADMIN
from invite_utils import generate_invite_identifier
def unique_email(prefix: str = "test") -> str: def unique_email(prefix: str = "test") -> str:
"""Generate a unique email for tests sharing the same database.""" """Generate a unique email for tests sharing the same database."""
return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com" return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com"
async def create_invite_for_registration(db: AsyncSession, godfather_email: str) -> str:
"""
Create an invite that can be used for registration.
Returns the invite identifier.
"""
# Find godfather
result = await db.execute(select(User).where(User.email == godfather_email))
godfather = result.scalar_one_or_none()
if not godfather:
# Create a godfather user (admin can create invites)
from auth import get_password_hash
from models import Role
result = await db.execute(select(Role).where(Role.name == ROLE_ADMIN))
admin_role = result.scalar_one_or_none()
godfather = User(
email=godfather_email,
hashed_password=get_password_hash("password123"),
roles=[admin_role] if admin_role else [],
)
db.add(godfather)
await db.flush()
# Create invite
identifier = generate_invite_identifier()
invite = Invite(
identifier=identifier,
godfather_id=godfather.id,
status=InviteStatus.READY,
)
db.add(invite)
await db.commit()
return identifier

View file

@ -1,16 +1,31 @@
"""Tests for authentication endpoints.
Note: Registration now requires an invite code. Tests that need to register
users will create invites first via the helper function.
"""
import pytest import pytest
from auth import COOKIE_NAME from auth import COOKIE_NAME
from tests.helpers import unique_email from tests.helpers import unique_email, create_invite_for_registration
# Registration tests # Registration tests (with invite)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_success(client): async def test_register_success(client_factory):
"""Can register with valid invite code."""
email = unique_email("register") email = unique_email("register")
response = await client.post(
# Create invite
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("godfather"))
response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -25,62 +40,110 @@ async def test_register_success(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_duplicate_email(client): async def test_register_duplicate_email(client_factory):
"""Cannot register with already-used email."""
email = unique_email("duplicate") email = unique_email("duplicate")
await client.post(
# Create two invites
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
# First registration
await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite1,
},
) )
response = await client.post(
# Second registration with same email
response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "differentpass"}, json={
"email": email,
"password": "differentpass",
"invite_identifier": invite2,
},
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json()["detail"] == "Email already registered" assert response.json()["detail"] == "Email already registered"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_invalid_email(client): async def test_register_invalid_email(client_factory):
response = await client.post( """Cannot register with invalid email format."""
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": "notanemail", "password": "password123"}, json={
"email": "notanemail",
"password": "password123",
"invite_identifier": invite_code,
},
) )
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_missing_password(client): async def test_register_missing_password(client):
"""Cannot register without password."""
response = await client.post( response = await client.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email()}, json={"email": unique_email(), "invite_identifier": "some-code-00"},
) )
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_missing_email(client): async def test_register_missing_email(client):
"""Cannot register without email."""
response = await client.post( response = await client.post(
"/api/auth/register", "/api/auth/register",
json={"password": "password123"}, json={"password": "password123", "invite_identifier": "some-code-00"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_missing_invite(client):
"""Cannot register without invite code."""
response = await client.post(
"/api/auth/register",
json={"email": unique_email(), "password": "password123"},
) )
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_empty_body(client): async def test_register_empty_body(client):
"""Cannot register with empty body."""
response = await client.post("/api/auth/register", json={}) response = await client.post("/api/auth/register", json={})
assert response.status_code == 422 assert response.status_code == 422
# Login tests # Login tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_success(client): async def test_login_success(client_factory):
"""Can login with valid credentials."""
email = unique_email("login") email = unique_email("login")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
response = await client.post( response = await client_factory.post(
"/api/auth/login", "/api/auth/login",
json={"email": email, "password": "password123"}, json={"email": email, "password": "password123"},
) )
@ -93,13 +156,22 @@ async def test_login_success(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_wrong_password(client): async def test_login_wrong_password(client_factory):
"""Cannot login with wrong password."""
email = unique_email("wrongpass") email = unique_email("wrongpass")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "correctpassword"}, json={
"email": email,
"password": "correctpassword",
"invite_identifier": invite_code,
},
) )
response = await client.post( response = await client_factory.post(
"/api/auth/login", "/api/auth/login",
json={"email": email, "password": "wrongpassword"}, json={"email": email, "password": "wrongpassword"},
) )
@ -109,6 +181,7 @@ async def test_login_wrong_password(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_nonexistent_user(client): async def test_login_nonexistent_user(client):
"""Cannot login with non-existent user."""
response = await client.post( response = await client.post(
"/api/auth/login", "/api/auth/login",
json={"email": unique_email("nonexistent"), "password": "password123"}, json={"email": unique_email("nonexistent"), "password": "password123"},
@ -119,6 +192,7 @@ async def test_login_nonexistent_user(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_invalid_email_format(client): async def test_login_invalid_email_format(client):
"""Cannot login with invalid email format."""
response = await client.post( response = await client.post(
"/api/auth/login", "/api/auth/login",
json={"email": "invalidemail", "password": "password123"}, json={"email": "invalidemail", "password": "password123"},
@ -128,6 +202,7 @@ async def test_login_invalid_email_format(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_missing_fields(client): async def test_login_missing_fields(client):
"""Cannot login with missing fields."""
response = await client.post("/api/auth/login", json={}) response = await client.post("/api/auth/login", json={})
assert response.status_code == 422 assert response.status_code == 422
@ -135,16 +210,22 @@ async def test_login_missing_fields(client):
# Get current user tests # Get current user tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_success(client_factory): async def test_get_me_success(client_factory):
"""Can get current user info when authenticated."""
email = unique_email("me") email = unique_email("me")
# Register and get cookies async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post( reg_response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg_response.cookies) cookies = dict(reg_response.cookies)
# Use authenticated client
async with client_factory.create(cookies=cookies) as authed: async with client_factory.create(cookies=cookies) as authed:
response = await authed.get("/api/auth/me") response = await authed.get("/api/auth/me")
@ -158,12 +239,14 @@ async def test_get_me_success(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_no_cookie(client): async def test_get_me_no_cookie(client):
"""Cannot get current user without auth cookie."""
response = await client.get("/api/auth/me") response = await client.get("/api/auth/me")
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_invalid_cookie(client_factory): async def test_get_me_invalid_cookie(client_factory):
"""Cannot get current user with invalid cookie."""
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken123"}) as authed: async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken123"}) as authed:
response = await authed.get("/api/auth/me") response = await authed.get("/api/auth/me")
assert response.status_code == 401 assert response.status_code == 401
@ -172,6 +255,7 @@ async def test_get_me_invalid_cookie(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_expired_token(client_factory): async def test_get_me_expired_token(client_factory):
"""Cannot get current user with expired token."""
bad_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MH0.invalid" bad_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MH0.invalid"
async with client_factory.create(cookies={COOKIE_NAME: bad_token}) as authed: async with client_factory.create(cookies={COOKIE_NAME: bad_token}) as authed:
response = await authed.get("/api/auth/me") response = await authed.get("/api/auth/me")
@ -181,11 +265,19 @@ async def test_get_me_expired_token(client_factory):
# Cookie validation tests # Cookie validation tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cookie_from_register_works_for_me(client_factory): async def test_cookie_from_register_works_for_me(client_factory):
"""Auth cookie from registration works for subsequent requests."""
email = unique_email("tokentest") email = unique_email("tokentest")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post( reg_response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg_response.cookies) cookies = dict(reg_response.cookies)
@ -198,11 +290,19 @@ async def test_cookie_from_register_works_for_me(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cookie_from_login_works_for_me(client_factory): async def test_cookie_from_login_works_for_me(client_factory):
"""Auth cookie from login works for subsequent requests."""
email = unique_email("logintoken") email = unique_email("logintoken")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post( await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
login_response = await client_factory.post( login_response = await client_factory.post(
"/api/auth/login", "/api/auth/login",
@ -220,16 +320,29 @@ async def test_cookie_from_login_works_for_me(client_factory):
# Multiple users tests # Multiple users tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_users_isolated(client_factory): async def test_multiple_users_isolated(client_factory):
"""Multiple users have isolated sessions."""
email1 = unique_email("user1") email1 = unique_email("user1")
email2 = unique_email("user2") email2 = unique_email("user2")
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
resp1 = await client_factory.post( resp1 = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email1, "password": "password1"}, json={
"email": email1,
"password": "password1",
"invite_identifier": invite1,
},
) )
resp2 = await client_factory.post( resp2 = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email2, "password": "password2"}, json={
"email": email2,
"password": "password2",
"invite_identifier": invite2,
},
) )
cookies1 = dict(resp1.cookies) cookies1 = dict(resp1.cookies)
@ -248,13 +361,22 @@ async def test_multiple_users_isolated(client_factory):
# Password tests # Password tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_is_hashed(client): async def test_password_is_hashed(client_factory):
"""Passwords are properly hashed (can login with correct password)."""
email = unique_email("hashtest") email = unique_email("hashtest")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "mySecurePassword123"}, json={
"email": email,
"password": "mySecurePassword123",
"invite_identifier": invite_code,
},
) )
response = await client.post( response = await client_factory.post(
"/api/auth/login", "/api/auth/login",
json={"email": email, "password": "mySecurePassword123"}, json={"email": email, "password": "mySecurePassword123"},
) )
@ -262,13 +384,22 @@ async def test_password_is_hashed(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_case_sensitive_password(client): async def test_case_sensitive_password(client_factory):
"""Passwords are case-sensitive."""
email = unique_email("casetest") email = unique_email("casetest")
await client.post(
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "Password123"}, json={
"email": email,
"password": "Password123",
"invite_identifier": invite_code,
},
) )
response = await client.post( response = await client_factory.post(
"/api/auth/login", "/api/auth/login",
json={"email": email, "password": "password123"}, json={"email": email, "password": "password123"},
) )
@ -278,11 +409,19 @@ async def test_case_sensitive_password(client):
# Logout tests # Logout tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_logout_success(client_factory): async def test_logout_success(client_factory):
"""Can logout successfully."""
email = unique_email("logout") email = unique_email("logout")
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg_response = await client_factory.post( reg_response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": email, "password": "password123"}, json={
"email": email,
"password": "password123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg_response.cookies) cookies = dict(reg_response.cookies)

View file

@ -1,7 +1,11 @@
"""Tests for counter endpoints.
Note: Registration now requires an invite code.
"""
import pytest import pytest
from auth import COOKIE_NAME from auth import COOKIE_NAME
from tests.helpers import unique_email from tests.helpers import unique_email, create_invite_for_registration
# Protected endpoint tests - without auth # Protected endpoint tests - without auth
@ -34,9 +38,16 @@ async def test_increment_counter_invalid_cookie(client_factory):
# Authenticated counter tests # Authenticated counter tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_counter_authenticated(client_factory): async def test_get_counter_authenticated(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post( reg = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email(), "password": "testpass123"}, json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg.cookies) cookies = dict(reg.cookies)
@ -49,9 +60,16 @@ async def test_get_counter_authenticated(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_increment_counter(client_factory): async def test_increment_counter(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post( reg = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email(), "password": "testpass123"}, json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg.cookies) cookies = dict(reg.cookies)
@ -68,9 +86,16 @@ async def test_increment_counter(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_increment_counter_multiple(client_factory): async def test_increment_counter_multiple(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post( reg = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email(), "password": "testpass123"}, json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg.cookies) cookies = dict(reg.cookies)
@ -89,9 +114,16 @@ async def test_increment_counter_multiple(client_factory):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_counter_after_increment(client_factory): async def test_get_counter_after_increment(client_factory):
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
reg = await client_factory.post( reg = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email(), "password": "testpass123"}, json={
"email": unique_email(),
"password": "testpass123",
"invite_identifier": invite_code,
},
) )
cookies = dict(reg.cookies) cookies = dict(reg.cookies)
@ -109,10 +141,19 @@ async def test_get_counter_after_increment(client_factory):
# Counter is shared between users # Counter is shared between users
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_counter_shared_between_users(client_factory): async def test_counter_shared_between_users(client_factory):
# Create invites for two users
async with client_factory.get_db_session() as db:
invite1 = await create_invite_for_registration(db, unique_email("gf1"))
invite2 = await create_invite_for_registration(db, unique_email("gf2"))
# Create first user # Create first user
reg1 = await client_factory.post( reg1 = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email("share1"), "password": "testpass123"}, json={
"email": unique_email("share1"),
"password": "testpass123",
"invite_identifier": invite1,
},
) )
cookies1 = dict(reg1.cookies) cookies1 = dict(reg1.cookies)
@ -127,7 +168,11 @@ async def test_counter_shared_between_users(client_factory):
# Create second user - should see the increments # Create second user - should see the increments
reg2 = await client_factory.post( reg2 = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email("share2"), "password": "testpass123"}, json={
"email": unique_email("share2"),
"password": "testpass123",
"invite_identifier": invite2,
},
) )
cookies2 = dict(reg2.cookies) cookies2 = dict(reg2.cookies)

File diff suppressed because it is too large Load diff

View file

@ -305,11 +305,18 @@ class TestSecurityBypassAttempts:
Test that new registrations cannot claim admin role. Test that new registrations cannot claim admin role.
New users should only get 'regular' role by default. New users should only get 'regular' role by default.
""" """
from tests.helpers import unique_email from tests.helpers import unique_email, create_invite_for_registration
async with client_factory.get_db_session() as db:
invite_code = await create_invite_for_registration(db, unique_email("gf"))
response = await client_factory.post( response = await client_factory.post(
"/api/auth/register", "/api/auth/register",
json={"email": unique_email(), "password": "password123"}, json={
"email": unique_email(),
"password": "password123",
"invite_identifier": invite_code,
},
) )
assert response.status_code == 200 assert response.status_code == 200

View file

@ -397,3 +397,57 @@ class TestProfilePrivacy:
assert "telegram" not in data assert "telegram" not in data
assert "signal" not in data assert "signal" not in data
assert "nostr_npub" not in data assert "nostr_npub" not in data
class TestProfileGodfather:
"""Tests for godfather information in profile."""
async def test_profile_shows_godfather_email(self, client_factory, admin_user, regular_user):
"""Profile shows godfather email for users who signed up with invite."""
from tests.helpers import unique_email
from sqlalchemy import select
from models import User
# Create invite
async with client_factory.create(cookies=admin_user["cookies"]) as client:
async with client_factory.get_db_session() as db:
result = await db.execute(
select(User).where(User.email == regular_user["email"])
)
godfather = result.scalar_one()
create_resp = await client.post(
"/api/admin/invites",
json={"godfather_id": godfather.id},
)
identifier = create_resp.json()["identifier"]
# Register new user with invite
new_email = unique_email("godchild")
async with client_factory.create() as client:
reg_resp = await client.post(
"/api/auth/register",
json={
"email": new_email,
"password": "password123",
"invite_identifier": identifier,
},
)
new_user_cookies = dict(reg_resp.cookies)
# Check profile shows godfather
async with client_factory.create(cookies=new_user_cookies) as client:
response = await client.get("/api/profile")
assert response.status_code == 200
data = response.json()
assert data["godfather_email"] == regular_user["email"]
async def test_profile_godfather_null_for_seeded_users(self, client_factory, regular_user):
"""Profile shows null godfather for users without one (e.g., seeded users)."""
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["godfather_email"] is None

2048
backend/words.txt Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,529 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
interface InviteRecord {
id: number;
identifier: string;
godfather_id: number;
godfather_email: string;
status: string;
used_by_id: number | null;
used_by_email: string | null;
created_at: string;
spent_at: string | null;
revoked_at: string | null;
}
interface PaginatedResponse<T> {
records: T[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface UserOption {
id: number;
email: string;
}
export default function AdminInvitesPage() {
const [data, setData] = useState<PaginatedResponse<InviteRecord> | null>(null);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
const [isCreating, setIsCreating] = useState(false);
const [newGodfatherId, setNewGodfatherId] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [users, setUsers] = useState<UserOption[]>([]);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_AUDIT, // Admins have this
fallbackRedirect: "/",
});
const fetchUsers = useCallback(async () => {
try {
const data = await api.get<UserOption[]>("/api/admin/users");
setUsers(data);
} catch (err) {
console.error("Failed to fetch users:", err);
}
}, []);
const fetchInvites = useCallback(async (page: number, status: string) => {
setError(null);
try {
let url = `/api/admin/invites?page=${page}&per_page=10`;
if (status) {
url += `&status=${status}`;
}
const data = await api.get<PaginatedResponse<InviteRecord>>(url);
setData(data);
} catch (err) {
setData(null);
setError(err instanceof Error ? err.message : "Failed to load invites");
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchUsers();
fetchInvites(page, statusFilter);
}
}, [user, page, statusFilter, isAuthorized, fetchUsers, fetchInvites]);
const handleCreateInvite = async () => {
if (!newGodfatherId) {
setCreateError("Please enter a godfather user ID");
return;
}
setIsCreating(true);
setCreateError(null);
try {
await api.post("/api/admin/invites", {
godfather_id: parseInt(newGodfatherId),
});
setNewGodfatherId("");
fetchInvites(1, statusFilter);
setPage(1);
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create invite");
} finally {
setIsCreating(false);
}
};
const handleRevoke = async (inviteId: number) => {
try {
await api.post(`/api/admin/invites/${inviteId}/revoke`);
fetchInvites(page, statusFilter);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke invite");
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
const getStatusBadgeStyle = (status: string) => {
switch (status) {
case "ready":
return styles.statusReady;
case "spent":
return styles.statusSpent;
case "revoked":
return styles.statusRevoked;
default:
return {};
}
};
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<Header currentPage="admin-invites" />
<div style={styles.content}>
<div style={styles.pageContainer}>
{/* Create Invite Section */}
<div style={styles.createCard}>
<h2 style={styles.createTitle}>Create Invite</h2>
<div style={styles.createForm}>
<div style={styles.inputGroup}>
<label style={styles.inputLabel}>Godfather (user who can share this invite)</label>
<select
value={newGodfatherId}
onChange={(e) => setNewGodfatherId(e.target.value)}
style={styles.select}
>
<option value="">Select a user...</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.email}
</option>
))}
</select>
{users.length === 0 && (
<span style={styles.inputHint}>
No users loaded yet. Create at least one invite to populate the list.
</span>
)}
</div>
{createError && <div style={styles.createError}>{createError}</div>}
<button
onClick={handleCreateInvite}
disabled={isCreating || !newGodfatherId}
style={{
...styles.createButton,
...(!newGodfatherId ? styles.createButtonDisabled : {}),
}}
>
{isCreating ? "Creating..." : "Create Invite"}
</button>
</div>
</div>
{/* Invites Table */}
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>All Invites</h2>
<div style={styles.filterGroup}>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
style={styles.filterSelect}
>
<option value="">All statuses</option>
<option value="ready">Ready</option>
<option value="spent">Spent</option>
<option value="revoked">Revoked</option>
</select>
<span style={styles.totalCount}>
{data?.total ?? 0} invites
</span>
</div>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Code</th>
<th style={styles.th}>Godfather</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Used By</th>
<th style={styles.th}>Created</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{error && (
<tr>
<td colSpan={6} style={styles.errorRow}>{error}</td>
</tr>
)}
{!error && data?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.tdCode}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td>
<td style={styles.td}>
<span style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}>
{record.status}
</span>
</td>
<td style={styles.td}>
{record.used_by_email || "-"}
</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}>
{record.status === "ready" && (
<button
onClick={() => handleRevoke(record.id)}
style={styles.revokeButton}
>
Revoke
</button>
)}
</td>
</tr>
))}
{!error && (!data || data.records.length === 0) && (
<tr>
<td colSpan={6} style={styles.emptyRow}>No invites yet</td>
</tr>
)}
</tbody>
</table>
</div>
{data && data.total_pages > 1 && (
<div style={styles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
>
</button>
<span style={styles.pageInfo}>
{page} / {data.total_pages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.total_pages, p + 1))}
disabled={page === data.total_pages}
style={styles.pageBtn}
>
</button>
</div>
)}
</div>
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
pageContainer: {
display: "flex",
flexDirection: "column",
gap: "2rem",
maxWidth: "1000px",
margin: "0 auto",
},
createCard: {
background: "rgba(99, 102, 241, 0.08)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: "16px",
padding: "1.5rem",
},
createTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.9)",
margin: "0 0 1rem 0",
},
createForm: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
inputGroup: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.5)",
},
input: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
maxWidth: "300px",
},
select: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
maxWidth: "400px",
cursor: "pointer",
},
createError: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
color: "#f87171",
},
createButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
fontWeight: 500,
padding: "0.75rem 1.5rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
alignSelf: "flex-start",
},
createButtonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
inputHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
fontStyle: "italic",
},
tableCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "20px",
padding: "1.5rem",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
tableHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
flexWrap: "wrap",
gap: "1rem",
},
tableTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "1.5rem",
fontWeight: 400,
color: "#fff",
margin: 0,
},
filterGroup: {
display: "flex",
alignItems: "center",
gap: "1rem",
},
filterSelect: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
padding: "0.5rem 1rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
},
totalCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.4)",
},
tableWrapper: {
overflowX: "auto",
},
table: {
width: "100%",
borderCollapse: "collapse",
fontFamily: "'DM Sans', system-ui, sans-serif",
},
th: {
textAlign: "left",
padding: "0.75rem 1rem",
fontSize: "0.75rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.4)",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
},
tr: {
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
},
td: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.7)",
},
tdCode: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "#fff",
fontFamily: "'DM Mono', monospace",
},
tdDate: {
padding: "0.875rem 1rem",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
statusBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
},
statusReady: {
background: "rgba(99, 102, 241, 0.2)",
color: "rgba(129, 140, 248, 0.9)",
},
statusSpent: {
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
},
statusRevoked: {
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
},
revokeButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
padding: "0.4rem 0.75rem",
background: "rgba(239, 68, 68, 0.15)",
color: "rgba(239, 68, 68, 0.9)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
cursor: "pointer",
},
emptyRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "rgba(255, 255, 255, 0.3)",
fontSize: "0.875rem",
},
errorRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "#f87171",
fontSize: "0.875rem",
},
pagination: {
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "1rem",
marginTop: "1rem",
paddingTop: "1rem",
borderTop: "1px solid rgba(255, 255, 255, 0.06)",
},
pageBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.5rem 1rem",
fontSize: "1rem",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
pageInfo: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -22,8 +22,8 @@ async function request<T>(
): Promise<T> { ): Promise<T> {
const url = `${API_URL}${endpoint}`; const url = `${API_URL}${endpoint}`;
const headers: HeadersInit = { const headers: Record<string, string> = {
...options.headers, ...(options.headers as Record<string, string>),
}; };
if (options.body && typeof options.body === "string") { if (options.body && typeof options.body === "string") {

View file

@ -10,6 +10,8 @@ export const Permission = {
INCREMENT_COUNTER: "increment_counter", INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum", USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit", VIEW_AUDIT: "view_audit",
MANAGE_INVITES: "manage_invites",
VIEW_OWN_INVITES: "view_own_invites",
} as const; } as const;
export type PermissionType = typeof Permission[keyof typeof Permission]; export type PermissionType = typeof Permission[keyof typeof Permission];
@ -25,7 +27,7 @@ interface AuthContextType {
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string, inviteIdentifier: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
hasPermission: (permission: PermissionType) => boolean; hasPermission: (permission: PermissionType) => boolean;
hasRole: (role: string) => boolean; hasRole: (role: string) => boolean;
@ -65,9 +67,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}; };
const register = async (email: string, password: string) => { const register = async (email: string, password: string, inviteIdentifier: string) => {
try { try {
const userData = await api.post<User>("/api/auth/register", { email, password }); const userData = await api.post<User>("/api/auth/register", {
email,
password,
invite_identifier: inviteIdentifier,
});
setUser(userData); setUser(userData);
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {

View file

@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context"; import { useAuth } from "../auth-context";
import { sharedStyles } from "../styles/shared"; import { sharedStyles } from "../styles/shared";
type PageId = "counter" | "sum" | "profile" | "audit"; type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites";
interface HeaderProps { interface HeaderProps {
currentPage: PageId; currentPage: PageId;
@ -15,18 +15,26 @@ interface NavItem {
label: string; label: string;
href: string; href: string;
regularOnly?: boolean; regularOnly?: boolean;
adminOnly?: boolean;
} }
const NAV_ITEMS: NavItem[] = [ const REGULAR_NAV_ITEMS: NavItem[] = [
{ id: "counter", label: "Counter", href: "/" }, { id: "counter", label: "Counter", href: "/" },
{ id: "sum", label: "Sum", href: "/sum" }, { id: "sum", label: "Sum", href: "/sum" },
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true }, { id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
]; ];
const ADMIN_NAV_ITEMS: NavItem[] = [
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
{ id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true },
];
export function Header({ currentPage }: HeaderProps) { export function Header({ currentPage }: HeaderProps) {
const { user, logout, hasRole } = useAuth(); const { user, logout, hasRole } = useAuth();
const router = useRouter(); const router = useRouter();
const isRegularUser = hasRole("regular"); const isRegularUser = hasRole("regular");
const isAdminUser = hasRole("admin");
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@ -35,12 +43,23 @@ export function Header({ currentPage }: HeaderProps) {
if (!user) return null; if (!user) return null;
// For audit page (admin), show only the current page label // For admin pages, show admin navigation
if (currentPage === "audit") { if (isAdminUser && (currentPage === "audit" || currentPage === "admin-invites")) {
return ( return (
<div style={sharedStyles.header}> <div style={sharedStyles.header}>
<div style={sharedStyles.nav}> <div style={sharedStyles.nav}>
<span style={sharedStyles.navCurrent}>Audit</span> {ADMIN_NAV_ITEMS.map((item, index) => (
<span key={item.id}>
{index > 0 && <span style={sharedStyles.navDivider}></span>}
{item.id === currentPage ? (
<span style={sharedStyles.navCurrent}>{item.label}</span>
) : (
<a href={item.href} style={sharedStyles.navLink}>
{item.label}
</a>
)}
</span>
))}
</div> </div>
<div style={sharedStyles.userInfo}> <div style={sharedStyles.userInfo}>
<span style={sharedStyles.userEmail}>{user.email}</span> <span style={sharedStyles.userEmail}>{user.email}</span>
@ -53,7 +72,7 @@ export function Header({ currentPage }: HeaderProps) {
} }
// For regular pages, build nav with links // For regular pages, build nav with links
const visibleItems = NAV_ITEMS.filter( const visibleItems = REGULAR_NAV_ITEMS.filter(
(item) => !item.regularOnly || isRegularUser (item) => !item.regularOnly || isRegularUser
); );

View file

@ -0,0 +1,330 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
interface Invite {
id: number;
identifier: string;
status: string;
used_by_email: string | null;
created_at: string;
spent_at: string | null;
}
export default function InvitesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredRole: "regular",
fallbackRedirect: "/audit",
});
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(true);
const [copiedId, setCopiedId] = useState<number | null>(null);
const fetchInvites = useCallback(async () => {
try {
const data = await api.get<Invite[]>("/api/invites");
setInvites(data);
} catch (err) {
console.error("Failed to load invites:", err);
} finally {
setIsLoadingInvites(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchInvites();
}
}, [user, isAuthorized, fetchInvites]);
const getInviteUrl = (identifier: string) => {
if (typeof window !== "undefined") {
return `${window.location.origin}/signup/${identifier}`;
}
return `/signup/${identifier}`;
};
const copyToClipboard = async (invite: Invite) => {
const url = getInviteUrl(invite.identifier);
try {
await navigator.clipboard.writeText(url);
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
if (isLoading || isLoadingInvites) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
const readyInvites = invites.filter((i) => i.status === "ready");
const spentInvites = invites.filter((i) => i.status === "spent");
const revokedInvites = invites.filter((i) => i.status === "revoked");
return (
<main style={styles.main}>
<Header currentPage="invites" />
<div style={styles.content}>
<div style={styles.pageCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
</div>
{invites.length === 0 ? (
<div style={styles.emptyState}>
<p style={styles.emptyText}>You don&apos;t have any invites yet.</p>
<p style={styles.emptyHint}>
Contact an admin if you need invite codes to share.
</p>
</div>
) : (
<div style={styles.sections}>
{/* Ready Invites */}
{readyInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available ({readyInvites.length})
</h2>
<p style={styles.sectionHint}>
Share these links with people you want to invite
</p>
<div style={styles.inviteList}>
{readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteActions}>
<button
onClick={() => copyToClipboard(invite)}
style={styles.copyButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Spent Invites */}
{spentInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Used ({spentInvites.length})
</h2>
<div style={styles.inviteList}>
{spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<span style={styles.statusBadgeSpent}>Used</span>
<span style={styles.inviteeEmail}>
by {invite.used_by_email}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Revoked Invites */}
{revokedInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Revoked ({revokedInvites.length})
</h2>
<div style={styles.inviteList}>
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<span style={styles.statusBadgeRevoked}>Revoked</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
pageCard: {
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: "600px",
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",
},
emptyState: {
textAlign: "center",
padding: "2rem 0",
},
emptyText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.6)",
fontSize: "1rem",
margin: 0,
},
emptyHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
fontSize: "0.85rem",
marginTop: "0.5rem",
},
sections: {
display: "flex",
flexDirection: "column",
gap: "2rem",
},
section: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
sectionTitle: {
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,
},
inviteList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
inviteCard: {
background: "rgba(99, 102, 241, 0.1)",
border: "1px solid rgba(99, 102, 241, 0.3)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
inviteCardSpent: {
background: "rgba(34, 197, 94, 0.08)",
border: "1px solid rgba(34, 197, 94, 0.2)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
inviteCardRevoked: {
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
},
inviteCode: {
fontFamily: "'DM Mono', monospace",
fontSize: "0.95rem",
color: "#fff",
letterSpacing: "0.02em",
},
inviteActions: {
display: "flex",
gap: "0.5rem",
},
copyButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
fontWeight: 500,
padding: "0.5rem 1rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
inviteeMeta: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
statusBadgeSpent: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
statusBadgeRevoked: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
inviteeEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.6)",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -12,6 +12,7 @@ interface ProfileData {
telegram: string | null; telegram: string | null;
signal: string | null; signal: string | null;
nostr_npub: string | null; nostr_npub: string | null;
godfather_email: string | null;
} }
interface FormData { interface FormData {
@ -122,6 +123,7 @@ export default function ProfilePage() {
signal: "", signal: "",
nostr_npub: "", nostr_npub: "",
}); });
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
const [isLoadingProfile, setIsLoadingProfile] = useState(true); const [isLoadingProfile, setIsLoadingProfile] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -150,6 +152,7 @@ export default function ProfilePage() {
const formValues = toFormData(data); const formValues = toFormData(data);
setFormData(formValues); setFormData(formValues);
setOriginalData(formValues); setOriginalData(formValues);
setGodfatherEmail(data.godfather_email);
} catch (err) { } catch (err) {
console.error("Profile load error:", err); console.error("Profile load error:", err);
setToast({ message: "Failed to load profile", type: "error" }); setToast({ message: "Failed to load profile", type: "error" });
@ -302,6 +305,22 @@ export default function ProfilePage() {
</span> </span>
</div> </div>
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={styles.field}>
<label style={styles.label}>
Invited By
<span style={styles.readOnlyBadge}>Read only</span>
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={styles.hint}>
The user who invited you to join.
</span>
</div>
)}
<div style={styles.divider} /> <div style={styles.divider} />
<p style={styles.sectionLabel}>Contact Details</p> <p style={styles.sectionLabel}>Contact Details</p>
@ -483,6 +502,17 @@ const pageStyles: Record<string, React.CSSProperties> = {
color: "rgba(255, 255, 255, 0.5)", color: "rgba(255, 255, 255, 0.5)",
cursor: "not-allowed", cursor: "not-allowed",
}, },
godfatherBox: {
padding: "0.875rem 1rem",
background: "rgba(99, 102, 241, 0.08)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: "12px",
},
godfatherEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "rgba(129, 140, 248, 0.9)",
},
inputError: { inputError: {
border: "1px solid rgba(239, 68, 68, 0.5)", border: "1px solid rgba(239, 68, 68, 0.5)",
boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)", boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)",

View file

@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuth } from "../../auth-context";
export default function SignupWithCodePage() {
const params = useParams();
const router = useRouter();
const { user } = useAuth();
const code = params.code as string;
useEffect(() => {
if (user) {
// Already logged in, redirect to home
router.replace("/");
} else {
// Redirect to signup with code as query param
router.replace(`/signup?code=${encodeURIComponent(code)}`);
}
}, [user, code, router]);
return (
<main style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
color: "rgba(255,255,255,0.6)",
fontFamily: "'DM Sans', system-ui, sans-serif",
}}>
Redirecting...
</main>
);
}

View file

@ -1,20 +1,85 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, Suspense } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "../auth-context"; import { useAuth } from "../auth-context";
import { api } from "../api";
import { authFormStyles as styles } from "../styles/auth-form"; import { authFormStyles as styles } from "../styles/auth-form";
export default function SignupPage() { interface InviteCheckResponse {
valid: boolean;
status?: string;
error?: string;
}
function SignupContent() {
const searchParams = useSearchParams();
const initialCode = searchParams.get("code") || "";
const [inviteCode, setInviteCode] = useState(initialCode);
const [inviteValid, setInviteValid] = useState<boolean | null>(null);
const [inviteError, setInviteError] = useState("");
const [isCheckingInvite, setIsCheckingInvite] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { register } = useAuth();
const { user, register } = useAuth();
const router = useRouter(); const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => { // Redirect if already logged in
useEffect(() => {
if (user) {
router.push("/");
}
}, [user, router]);
// Check invite code on mount if provided in URL
useEffect(() => {
if (initialCode) {
checkInvite(initialCode);
}
}, [initialCode]);
const checkInvite = async (code: string) => {
if (!code.trim()) {
setInviteValid(null);
setInviteError("");
return;
}
setIsCheckingInvite(true);
setInviteError("");
try {
const response = await api.get<InviteCheckResponse>(
`/api/invites/${encodeURIComponent(code.trim())}/check`
);
if (response.valid) {
setInviteValid(true);
setInviteError("");
} else {
setInviteValid(false);
setInviteError(response.error || "Invalid invite code");
}
} catch {
setInviteValid(false);
setInviteError("Failed to verify invite code");
} finally {
setIsCheckingInvite(false);
}
};
const handleInviteSubmit = (e: React.FormEvent) => {
e.preventDefault();
checkInvite(inviteCode);
};
const handleSignupSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@ -31,7 +96,7 @@ export default function SignupPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await register(email, password); await register(email, password, inviteCode.trim());
router.push("/"); router.push("/");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Registration failed"); setError(err instanceof Error ? err.message : "Registration failed");
@ -40,16 +105,88 @@ export default function SignupPage() {
} }
}; };
// Show loading or redirect if user is already logged in
if (user) {
return null;
}
// Step 1: Enter invite code
if (!inviteValid) {
return (
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>
<h1 style={styles.title}>Join with Invite</h1>
<p style={styles.subtitle}>Enter your invite code to get started</p>
</div>
<form onSubmit={handleInviteSubmit} style={styles.form}>
{inviteError && <div style={styles.error}>{inviteError}</div>}
<div style={styles.field}>
<label htmlFor="inviteCode" style={styles.label}>Invite Code</label>
<input
id="inviteCode"
type="text"
value={inviteCode}
onChange={(e) => {
setInviteCode(e.target.value);
setInviteError("");
setInviteValid(null);
}}
style={styles.input}
placeholder="word-word-00"
required
autoFocus
/>
<span style={{ ...styles.link, fontSize: "0.8rem", marginTop: "0.5rem", display: "block" }}>
Ask your inviter for this code
</span>
</div>
<button
type="submit"
style={{
...styles.button,
opacity: isCheckingInvite ? 0.7 : 1,
}}
disabled={isCheckingInvite || !inviteCode.trim()}
>
{isCheckingInvite ? "Checking..." : "Continue"}
</button>
</form>
<p style={styles.footer}>
Already have an account?{" "}
<a href="/login" style={styles.link}>
Sign in
</a>
</p>
</div>
</div>
</main>
);
}
// Step 2: Enter email and password
return ( return (
<main style={styles.main}> <main style={styles.main}>
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>Create account</h1> <h1 style={styles.title}>Create account</h1>
<p style={styles.subtitle}>Get started with your journey</p> <p style={styles.subtitle}>
Using invite: <code style={{
background: "rgba(255,255,255,0.1)",
padding: "0.2rem 0.5rem",
borderRadius: "4px",
fontSize: "0.85rem"
}}>{inviteCode}</code>
</p>
</div> </div>
<form onSubmit={handleSubmit} style={styles.form}> <form onSubmit={handleSignupSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>} {error && <div style={styles.error}>{error}</div>}
<div style={styles.field}> <div style={styles.field}>
@ -62,6 +199,7 @@ export default function SignupPage() {
style={styles.input} style={styles.input}
placeholder="you@example.com" placeholder="you@example.com"
required required
autoFocus
/> />
</div> </div>
@ -104,13 +242,42 @@ export default function SignupPage() {
</form> </form>
<p style={styles.footer}> <p style={styles.footer}>
Already have an account?{" "} <button
<a href="/login" style={styles.link}> onClick={() => {
Sign in setInviteValid(null);
</a> setInviteError("");
}}
style={{
...styles.link,
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
Use a different invite code
</button>
</p> </p>
</div> </div>
</div> </div>
</main> </main>
); );
} }
export default function SignupPage() {
return (
<Suspense fallback={
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
Loading...
</div>
</div>
</div>
</main>
}>
<SignupContent />
</Suspense>
);
}

View file

@ -0,0 +1,162 @@
import { test, expect, Page } from "@playwright/test";
// Admin credentials from seed data
const ADMIN_EMAIL = "admin@example.com";
const ADMIN_PASSWORD = "admin123";
// Regular user from seed data
const REGULAR_USER_EMAIL = "user@example.com";
async function loginAsAdmin(page: Page) {
await page.goto("/login");
await page.fill('input[type="email"]', ADMIN_EMAIL);
await page.fill('input[type="password"]', ADMIN_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/audit");
}
test.describe("Admin Invites Page", () => {
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await loginAsAdmin(page);
});
test("admin can access invites page", async ({ page }) => {
await page.goto("/admin/invites");
await expect(page.getByRole("heading", { name: "Create Invite" })).toBeVisible();
await expect(page.getByRole("heading", { name: "All Invites" })).toBeVisible();
});
test("godfather selection is a dropdown with users, not a number input", async ({ page }) => {
await page.goto("/admin/invites");
// Wait for users to load
await page.waitForSelector("select");
// The godfather selector should be a <select> element, not an <input type="number">
const selectElement = page.locator("select").first();
await expect(selectElement).toBeVisible();
// Verify it has user options (at least the seeded users)
const options = selectElement.locator("option");
const optionCount = await options.count();
// Should have at least 2 options: placeholder + at least one user
expect(optionCount).toBeGreaterThanOrEqual(2);
// Verify the regular user appears as an option
await expect(selectElement).toContainText(REGULAR_USER_EMAIL);
// There should NOT be a number input for godfather ID
const numberInput = page.locator('input[type="number"]');
await expect(numberInput).toHaveCount(0);
});
test("can create invite by selecting user from dropdown", async ({ page }) => {
await page.goto("/admin/invites");
// Wait for page to load
await page.waitForSelector("select");
// Select the regular user as godfather
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
// Click create invite
await page.click('button:has-text("Create Invite")');
// Wait for the invite to appear in the table
await expect(page.locator("table")).toContainText(REGULAR_USER_EMAIL);
// Verify an invite code appears (format: word-word-NN)
const inviteCodeCell = page.locator("td").first();
await expect(inviteCodeCell).toHaveText(/^[a-z]+-[a-z]+-\d{2}$/);
});
test("create button is disabled when no user selected", async ({ page }) => {
await page.goto("/admin/invites");
// Wait for page to load
await page.waitForSelector("select");
// The create button should be disabled initially (no user selected)
const createButton = page.locator('button:has-text("Create Invite")');
await expect(createButton).toBeDisabled();
// Select a user
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
// Now the button should be enabled
await expect(createButton).toBeEnabled();
});
test("can revoke a ready invite", async ({ page }) => {
await page.goto("/admin/invites");
await page.waitForSelector("select");
// Create an invite first
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
await page.click('button:has-text("Create Invite")');
// Wait for the invite to appear
await expect(page.locator("table")).toContainText("ready");
// Click revoke on the first ready invite
const revokeButton = page.locator('button:has-text("Revoke")').first();
await revokeButton.click();
// Verify the status changed to revoked
await expect(page.locator("table")).toContainText("revoked");
});
test("status filter works", async ({ page }) => {
await page.goto("/admin/invites");
await page.waitForSelector("select");
// Create an invite
const godfatherSelect = page.locator("select").first();
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
await page.click('button:has-text("Create Invite")');
await expect(page.locator("table")).toContainText("ready");
// Filter by "revoked" status - should show no ready invites
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
await statusFilter.selectOption("revoked");
// Wait for the filter to apply
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
// Filter by "ready" status - should show our invite
await statusFilter.selectOption("ready");
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
await expect(page.locator("table")).toContainText("ready");
});
});
test.describe("Admin Invites Access Control", () => {
test("regular user cannot access admin invites page", async ({ page }) => {
// Login as regular user
await page.goto("/login");
await page.fill('input[type="email"]', REGULAR_USER_EMAIL);
await page.fill('input[type="password"]', "user123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Try to access admin invites page
await page.goto("/admin/invites");
// Should be redirected away (to home page based on fallbackRedirect)
await expect(page).not.toHaveURL("/admin/invites");
});
test("unauthenticated user cannot access admin invites page", async ({ page }) => {
await page.context().clearCookies();
await page.goto("/admin/invites");
// Should be redirected to login
await expect(page).toHaveURL("/login");
});
});

View file

@ -1,4 +1,4 @@
import { test, expect, Page } from "@playwright/test"; import { test, expect, Page, APIRequestContext } from "@playwright/test";
// Helper to generate unique email for each test // Helper to generate unique email for each test
function uniqueEmail(): string { function uniqueEmail(): string {
@ -10,6 +10,35 @@ async function clearAuth(page: Page) {
await page.context().clearCookies(); await page.context().clearCookies();
} }
// Admin credentials from seed data
const ADMIN_EMAIL = "admin@example.com";
const ADMIN_PASSWORD = "admin123";
// Helper to create an invite via the API
const API_BASE = "http://localhost:8000";
async function createInvite(request: APIRequestContext): Promise<string> {
// Login as admin
const loginResp = await request.post(`${API_BASE}/api/auth/login`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
const cookies = loginResp.headers()["set-cookie"];
// Get admin user ID (we'll use admin as godfather for simplicity)
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
headers: { Cookie: cookies },
});
const admin = await meResp.json();
// Create invite
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
data: { godfather_id: admin.id },
headers: { Cookie: cookies },
});
const invite = await inviteResp.json();
return invite.identifier;
}
test.describe("Authentication Flow", () => { test.describe("Authentication Flow", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await clearAuth(page); await clearAuth(page);
@ -29,13 +58,11 @@ test.describe("Authentication Flow", () => {
await expect(page.locator('a[href="/signup"]')).toBeVisible(); await expect(page.locator('a[href="/signup"]')).toBeVisible();
}); });
test("signup page has correct form elements", async ({ page }) => { test("signup page has invite code form", async ({ page }) => {
await page.goto("/signup"); await page.goto("/signup");
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Join with Invite");
await expect(page.locator('input[type="email"]')).toBeVisible(); await expect(page.locator('input#inviteCode')).toBeVisible();
await expect(page.locator('input[type="password"]').first()).toBeVisible(); await expect(page.locator('button[type="submit"]')).toHaveText("Continue");
await expect(page.locator('input[type="password"]').nth(1)).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText("Create account");
await expect(page.locator('a[href="/login"]')).toBeVisible(); await expect(page.locator('a[href="/login"]')).toBeVisible();
}); });
@ -52,18 +79,28 @@ test.describe("Authentication Flow", () => {
}); });
}); });
test.describe("Signup", () => { test.describe("Signup with Invite", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await clearAuth(page); await clearAuth(page);
}); });
test("can create a new account", async ({ page }) => { test("can create a new account with valid invite", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "password123"); // Step 1: Enter invite code
await page.locator('input[type="password"]').nth(1).fill("password123"); await page.fill('input#inviteCode', inviteCode);
await page.click('button[type="submit"]');
// Wait for form to transition to registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Step 2: Fill registration form
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Should redirect to home after signup // Should redirect to home after signup
@ -72,76 +109,90 @@ test.describe("Signup", () => {
await expect(page.getByText(email)).toBeVisible(); await expect(page.getByText(email)).toBeVisible();
}); });
test("shows error for duplicate email", async ({ page }) => { test("signup with direct invite URL works", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
// First registration // Use direct URL with code
await page.goto("/signup"); await page.goto(`/signup/${inviteCode}`);
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "password123"); // Should redirect to signup with code in query and validate
await page.locator('input[type="password"]').nth(1).fill("password123"); await page.waitForURL(/\/signup\?code=/);
// Wait for form to transition to registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration form
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/");
// Clear cookies and try again with same email // Should redirect to home
await clearAuth(page); await expect(page).toHaveURL("/");
});
test("shows error for invalid invite code", async ({ page }) => {
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', "fake-code-99");
await page.fill('input[type="password"]', "password123");
await page.locator('input[type="password"]').nth(1).fill("password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Should show error // Should show error
await expect(page.getByText("Email already registered")).toBeVisible(); await expect(page.getByText(/not found/i)).toBeVisible();
}); });
test("shows error for password mismatch", async ({ page }) => { test("shows error for password mismatch", async ({ page, request }) => {
const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', uniqueEmail()); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("differentpassword");
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail());
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "differentpassword");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.getByText("Passwords do not match")).toBeVisible(); await expect(page.getByText("Passwords do not match")).toBeVisible();
}); });
test("shows error for short password", async ({ page }) => { test("shows error for short password", async ({ page, request }) => {
const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', uniqueEmail()); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "short"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("short");
await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail());
await page.fill('input#password', "short");
await page.fill('input#confirmPassword', "short");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.getByText("Password must be at least 6 characters")).toBeVisible(); await expect(page.getByText("Password must be at least 6 characters")).toBeVisible();
}); });
test("shows loading state while submitting", async ({ page }) => {
await page.goto("/signup");
await page.fill('input[type="email"]', uniqueEmail());
await page.fill('input[type="password"]', "password123");
await page.locator('input[type="password"]').nth(1).fill("password123");
// Start submission and check for loading state
const submitPromise = page.click('button[type="submit"]');
await expect(page.locator('button[type="submit"]')).toHaveText("Creating account...");
await submitPromise;
});
}); });
test.describe("Login", () => { test.describe("Login", () => {
const testEmail = `login-test-${Date.now()}@example.com`; let testEmail: string;
const testPassword = "testpassword123"; const testPassword = "testpassword123";
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ request }) => {
// Create a test user // Create a test user with invite
const page = await browser.newPage(); testEmail = uniqueEmail();
await page.goto("/signup"); const inviteCode = await createInvite(request);
await page.fill('input[type="email"]', testEmail);
await page.fill('input[type="password"]', testPassword); // Register the test user via backend API
await page.locator('input[type="password"]').nth(1).fill(testPassword); await request.post(`${API_BASE}/api/auth/register`, {
await page.click('button[type="submit"]'); data: {
await expect(page).toHaveURL("/"); email: testEmail,
await page.close(); password: testPassword,
invite_identifier: inviteCode,
},
});
}); });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -188,14 +239,19 @@ test.describe("Login", () => {
}); });
test.describe("Logout", () => { test.describe("Logout", () => {
test("can logout", async ({ page }) => { test("can logout", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
// Sign up first // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -206,14 +262,19 @@ test.describe("Logout", () => {
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
test("cannot access home after logout", async ({ page }) => { test("cannot access home after logout", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
// Sign up // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -228,14 +289,19 @@ test.describe("Logout", () => {
}); });
test.describe("Session Persistence", () => { test.describe("Session Persistence", () => {
test("session persists after page reload", async ({ page }) => { test("session persists after page reload", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
// Sign up // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
await expect(page.getByText(email)).toBeVisible(); await expect(page.getByText(email)).toBeVisible();
@ -248,13 +314,18 @@ test.describe("Session Persistence", () => {
await expect(page.getByText(email)).toBeVisible(); await expect(page.getByText(email)).toBeVisible();
}); });
test("auth cookie is set after login", async ({ page }) => { test("auth cookie is set after signup", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -265,24 +336,26 @@ test.describe("Session Persistence", () => {
expect(authCookie!.httpOnly).toBe(true); expect(authCookie!.httpOnly).toBe(true);
}); });
test("auth cookie is cleared on logout", async ({ page }) => { test("auth cookie is cleared on logout", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
await page.click("text=Sign out"); await page.click("text=Sign out");
// Wait for navigation to complete - ensures the logout request finished
// and the Set-Cookie header was processed by the browser
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find((c) => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
// Cookie should be deleted or have empty value
expect(!authCookie || authCookie.value === "").toBe(true); expect(!authCookie || authCookie.value === "").toBe(true);
}); });
}); });

View file

@ -1,39 +1,75 @@
import { test, expect, Page } from "@playwright/test"; import { test, expect, Page, APIRequestContext } from "@playwright/test";
const API_BASE = "http://localhost:8000";
const ADMIN_EMAIL = "admin@example.com";
const ADMIN_PASSWORD = "admin123";
// Helper to generate unique email for each test // Helper to generate unique email for each test
function uniqueEmail(): string { function uniqueEmail(): string {
return `counter-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; return `counter-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`;
} }
// Helper to authenticate a user // Helper to create an invite via API
async function authenticate(page: Page): Promise<string> { async function createInvite(request: APIRequestContext): Promise<string> {
const loginResp = await request.post(`${API_BASE}/api/auth/login`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
const cookies = loginResp.headers()["set-cookie"];
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
headers: { Cookie: cookies },
});
const admin = await meResp.json();
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
data: { godfather_id: admin.id },
headers: { Cookie: cookies },
});
const invite = await inviteResp.json();
return invite.identifier;
}
// Helper to authenticate a user with invite-based signup
async function authenticate(page: Page, request: APIRequestContext): Promise<string> {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
await page.context().clearCookies(); await page.context().clearCookies();
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "password123"); // Enter invite code first
await page.locator('input[type="password"]').nth(1).fill("password123"); await page.fill('input#inviteCode', inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Wait for registration form
await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
return email; return email;
} }
test.describe("Counter - Authenticated", () => { test.describe("Counter - Authenticated", () => {
test("displays counter value", async ({ page }) => { test("displays counter value", async ({ page, request }) => {
await authenticate(page); await authenticate(page, request);
await expect(page.locator("h1")).toBeVisible(); await expect(page.locator("h1")).toBeVisible();
// Counter should be a number (not loading state) // Counter should be a number (not loading state)
const text = await page.locator("h1").textContent(); const text = await page.locator("h1").textContent();
expect(text).toMatch(/^\d+$/); expect(text).toMatch(/^\d+$/);
}); });
test("displays current count label", async ({ page }) => { test("displays current count label", async ({ page, request }) => {
await authenticate(page); await authenticate(page, request);
await expect(page.getByText("Current Count")).toBeVisible(); await expect(page.getByText("Current Count")).toBeVisible();
}); });
test("clicking increment button increases counter", async ({ page }) => { test("clicking increment button increases counter", async ({ page, request }) => {
await authenticate(page); await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("..."); await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent(); const before = await page.locator("h1").textContent();
@ -41,8 +77,8 @@ test.describe("Counter - Authenticated", () => {
await expect(page.locator("h1")).toHaveText(String(Number(before) + 1)); await expect(page.locator("h1")).toHaveText(String(Number(before) + 1));
}); });
test("clicking increment multiple times", async ({ page }) => { test("clicking increment multiple times", async ({ page, request }) => {
await authenticate(page); await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("..."); await expect(page.locator("h1")).not.toHaveText("...");
const before = Number(await page.locator("h1").textContent()); const before = Number(await page.locator("h1").textContent());
@ -64,8 +100,8 @@ test.describe("Counter - Authenticated", () => {
expect(final).toBeGreaterThanOrEqual(before + 3); expect(final).toBeGreaterThanOrEqual(before + 3);
}); });
test("counter persists after page reload", async ({ page }) => { test("counter persists after page reload", async ({ page, request }) => {
await authenticate(page); await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("..."); await expect(page.locator("h1")).not.toHaveText("...");
const before = await page.locator("h1").textContent(); const before = await page.locator("h1").textContent();
@ -77,9 +113,9 @@ test.describe("Counter - Authenticated", () => {
await expect(page.locator("h1")).toHaveText(expected); await expect(page.locator("h1")).toHaveText(expected);
}); });
test("counter is shared between users", async ({ page, browser }) => { test("counter is shared between users", async ({ page, browser, request }) => {
// First user increments // First user increments
await authenticate(page); await authenticate(page, request);
await expect(page.locator("h1")).not.toHaveText("..."); await expect(page.locator("h1")).not.toHaveText("...");
const initialValue = Number(await page.locator("h1").textContent()); const initialValue = Number(await page.locator("h1").textContent());
@ -92,7 +128,7 @@ test.describe("Counter - Authenticated", () => {
// Second user in new context sees the current value // Second user in new context sees the current value
const page2 = await browser.newPage(); const page2 = await browser.newPage();
await authenticate(page2); await authenticate(page2, request);
await expect(page2.locator("h1")).not.toHaveText("..."); await expect(page2.locator("h1")).not.toHaveText("...");
const page2InitialValue = Number(await page2.locator("h1").textContent()); const page2InitialValue = Number(await page2.locator("h1").textContent());
// The value should be at least what user 1 saw (might be higher due to parallel tests) // The value should be at least what user 1 saw (might be higher due to parallel tests)
@ -130,14 +166,19 @@ test.describe("Counter - Unauthenticated", () => {
}); });
test.describe("Counter - Session Integration", () => { test.describe("Counter - Session Integration", () => {
test("can access counter after login", async ({ page }) => { test("can access counter after login", async ({ page, request }) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request);
// Sign up first // Sign up with invite
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input[type="email"]', email); await page.fill('input#inviteCode', inviteCode);
await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
await page.locator('input[type="password"]').nth(1).fill("password123"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email);
await page.fill('input#password', "password123");
await page.fill('input#confirmPassword', "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");

File diff suppressed because one or more lines are too long