implemented
This commit is contained in:
parent
a31bd8246c
commit
d3638e2e69
18 changed files with 1643 additions and 120 deletions
32
Makefile
32
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: install-backend install-frontend install backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck
|
.PHONY: install-backend install-frontend install backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants
|
||||||
|
|
||||||
-include .env
|
-include .env
|
||||||
export
|
export
|
||||||
|
|
@ -54,7 +54,33 @@ test-frontend:
|
||||||
test-e2e: db-clean db-ready
|
test-e2e: db-clean db-ready
|
||||||
./scripts/e2e.sh
|
./scripts/e2e.sh
|
||||||
|
|
||||||
test: test-backend test-frontend test-e2e
|
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
||||||
|
|
||||||
typecheck:
|
typecheck: generate-types-standalone
|
||||||
cd backend && uv run mypy .
|
cd backend && uv run mypy .
|
||||||
|
cd frontend && npx tsc --noEmit
|
||||||
|
|
||||||
|
generate-types:
|
||||||
|
cd frontend && npm run generate-api-types
|
||||||
|
|
||||||
|
generate-types-standalone: db-seed
|
||||||
|
@echo "Starting backend for type generation..."
|
||||||
|
@cd backend && uv run uvicorn main:app --port 8000 --log-level warning & \
|
||||||
|
BACKEND_PID=$$!; \
|
||||||
|
sleep 3; \
|
||||||
|
cd frontend && npm run generate-api-types; \
|
||||||
|
EXIT_CODE=$$?; \
|
||||||
|
kill $$BACKEND_PID 2>/dev/null || true; \
|
||||||
|
exit $$EXIT_CODE
|
||||||
|
|
||||||
|
check-types-fresh: generate-types-standalone
|
||||||
|
@if git diff --quiet frontend/app/generated/api.ts 2>/dev/null; then \
|
||||||
|
echo "✓ Generated types are up to date"; \
|
||||||
|
else \
|
||||||
|
echo "✗ Generated types are stale. Run 'make generate-types-standalone' and commit."; \
|
||||||
|
git diff frontend/app/generated/api.ts; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
check-constants:
|
||||||
|
@cd backend && uv run python validate_constants.py
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,16 @@ from routes import audit as audit_routes
|
||||||
from routes import profile as profile_routes
|
from routes import profile as profile_routes
|
||||||
from routes import invites as invites_routes
|
from routes import invites as invites_routes
|
||||||
from routes import auth as auth_routes
|
from routes import auth as auth_routes
|
||||||
|
from routes import meta as meta_routes
|
||||||
|
from validate_constants import validate_shared_constants
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Create database tables on startup."""
|
"""Create database tables on startup and validate constants."""
|
||||||
|
# Validate shared constants match backend definitions
|
||||||
|
validate_shared_constants()
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
yield
|
yield
|
||||||
|
|
@ -39,3 +44,4 @@ app.include_router(audit_routes.router)
|
||||||
app.include_router(profile_routes.router)
|
app.include_router(profile_routes.router)
|
||||||
app.include_router(invites_routes.router)
|
app.include_router(invites_routes.router)
|
||||||
app.include_router(invites_routes.admin_router)
|
app.include_router(invites_routes.admin_router)
|
||||||
|
app.include_router(meta_routes.router)
|
||||||
|
|
|
||||||
18
backend/routes/meta.py
Normal file
18
backend/routes/meta.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Meta endpoints for shared constants."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from models import Permission, InviteStatus, ROLE_ADMIN, ROLE_REGULAR
|
||||||
|
from schemas import ConstantsResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/meta", tags=["meta"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/constants", response_model=ConstantsResponse)
|
||||||
|
async def get_constants() -> ConstantsResponse:
|
||||||
|
"""Get shared constants for frontend/backend synchronization."""
|
||||||
|
return ConstantsResponse(
|
||||||
|
permissions=[p.value for p in Permission],
|
||||||
|
roles=[ROLE_ADMIN, ROLE_REGULAR],
|
||||||
|
invite_statuses=[s.value for s in InviteStatus],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -138,3 +138,14 @@ class AdminUserResponse(BaseModel):
|
||||||
"""Minimal user info for admin dropdowns."""
|
"""Minimal user info for admin dropdowns."""
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Meta/Constants Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ConstantsResponse(BaseModel):
|
||||||
|
"""Response model for shared constants."""
|
||||||
|
permissions: list[str]
|
||||||
|
roles: list[str]
|
||||||
|
invite_statuses: list[str]
|
||||||
|
|
|
||||||
48
backend/validate_constants.py
Normal file
48
backend/validate_constants.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Validate shared constants match backend definitions."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from models import ROLE_ADMIN, ROLE_REGULAR, InviteStatus
|
||||||
|
|
||||||
|
|
||||||
|
def validate_shared_constants() -> None:
|
||||||
|
"""
|
||||||
|
Validate that shared/constants.json matches backend definitions.
|
||||||
|
Raises ValueError if there's a mismatch.
|
||||||
|
"""
|
||||||
|
constants_path = Path(__file__).parent.parent / "shared" / "constants.json"
|
||||||
|
|
||||||
|
if not constants_path.exists():
|
||||||
|
raise ValueError(f"Shared constants file not found: {constants_path}")
|
||||||
|
|
||||||
|
with open(constants_path) as f:
|
||||||
|
constants = json.load(f)
|
||||||
|
|
||||||
|
# Validate roles
|
||||||
|
expected_roles = {"ADMIN": ROLE_ADMIN, "REGULAR": ROLE_REGULAR}
|
||||||
|
if constants.get("roles") != expected_roles:
|
||||||
|
raise ValueError(
|
||||||
|
f"Role mismatch in shared/constants.json. "
|
||||||
|
f"Expected: {expected_roles}, Got: {constants.get('roles')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate invite statuses
|
||||||
|
expected_statuses = {s.name: s.value for s in InviteStatus}
|
||||||
|
if constants.get("inviteStatuses") != expected_statuses:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invite status mismatch in shared/constants.json. "
|
||||||
|
f"Expected: {expected_statuses}, Got: {constants.get('inviteStatuses')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate validation rules exist (structure check only)
|
||||||
|
validation = constants.get("validation", {})
|
||||||
|
required_fields = ["telegram", "signal", "nostrNpub"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in validation:
|
||||||
|
raise ValueError(f"Missing validation rules for '{field}' in shared/constants.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
validate_shared_constants()
|
||||||
|
print("✓ Shared constants are valid")
|
||||||
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
"""Validation utilities for user profile fields."""
|
"""Validation utilities for user profile fields."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from email_validator import validate_email, EmailNotValidError
|
from email_validator import validate_email, EmailNotValidError
|
||||||
from bech32 import bech32_decode
|
from bech32 import bech32_decode
|
||||||
|
|
||||||
|
# Load validation rules from shared constants
|
||||||
|
_constants_path = Path(__file__).parent.parent / "shared" / "constants.json"
|
||||||
|
with open(_constants_path) as f:
|
||||||
|
_constants = json.load(f)
|
||||||
|
|
||||||
|
TELEGRAM_RULES = _constants["validation"]["telegram"]
|
||||||
|
SIGNAL_RULES = _constants["validation"]["signal"]
|
||||||
|
NPUB_RULES = _constants["validation"]["nostrNpub"]
|
||||||
|
|
||||||
|
|
||||||
def validate_contact_email(value: str | None) -> str | None:
|
def validate_contact_email(value: str | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -24,22 +36,25 @@ def validate_telegram(value: str | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
Validate Telegram handle.
|
Validate Telegram handle.
|
||||||
|
|
||||||
Must start with @ if provided, with 1-32 characters after @.
|
Must start with @ if provided, with characters after @ within max length.
|
||||||
Returns None if valid, error message if invalid.
|
Returns None if valid, error message if invalid.
|
||||||
Empty/None values are valid (field is optional).
|
Empty/None values are valid (field is optional).
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not value.startswith("@"):
|
prefix = TELEGRAM_RULES["mustStartWith"]
|
||||||
return "Telegram handle must start with @"
|
max_len = TELEGRAM_RULES["maxLengthAfterAt"]
|
||||||
|
|
||||||
|
if not value.startswith(prefix):
|
||||||
|
return f"Telegram handle must start with {prefix}"
|
||||||
|
|
||||||
handle = value[1:]
|
handle = value[1:]
|
||||||
if not handle:
|
if not handle:
|
||||||
return "Telegram handle must have at least one character after @"
|
return f"Telegram handle must have at least one character after {prefix}"
|
||||||
|
|
||||||
if len(handle) > 32:
|
if len(handle) > max_len:
|
||||||
return "Telegram handle must be at most 32 characters (after @)"
|
return f"Telegram handle must be at most {max_len} characters (after {prefix})"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -48,19 +63,21 @@ def validate_signal(value: str | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
Validate Signal username.
|
Validate Signal username.
|
||||||
|
|
||||||
Any non-empty string is valid.
|
Any non-empty string within max length is valid.
|
||||||
Returns None if valid, error message if invalid.
|
Returns None if valid, error message if invalid.
|
||||||
Empty/None values are valid (field is optional).
|
Empty/None values are valid (field is optional).
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
max_len = SIGNAL_RULES["maxLength"]
|
||||||
|
|
||||||
# Signal usernames are fairly permissive, just check it's not empty
|
# Signal usernames are fairly permissive, just check it's not empty
|
||||||
if len(value.strip()) == 0:
|
if len(value.strip()) == 0:
|
||||||
return "Signal username cannot be empty"
|
return "Signal username cannot be empty"
|
||||||
|
|
||||||
if len(value) > 64:
|
if len(value) > max_len:
|
||||||
return "Signal username must be at most 64 characters"
|
return f"Signal username must be at most {max_len} characters"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -76,8 +93,11 @@ def validate_nostr_npub(value: str | None) -> str | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not value.startswith("npub1"):
|
prefix = NPUB_RULES["prefix"]
|
||||||
return "Nostr npub must start with 'npub1'"
|
expected_words = NPUB_RULES["bech32Words"]
|
||||||
|
|
||||||
|
if not value.startswith(prefix):
|
||||||
|
return f"Nostr npub must start with '{prefix}'"
|
||||||
|
|
||||||
# Decode bech32 to validate checksum
|
# Decode bech32 to validate checksum
|
||||||
hrp, data = bech32_decode(value)
|
hrp, data = bech32_decode(value)
|
||||||
|
|
@ -90,7 +110,7 @@ def validate_nostr_npub(value: str | None) -> str | None:
|
||||||
|
|
||||||
# npub should decode to 32 bytes (256 bits) for a public key
|
# npub should decode to 32 bytes (256 bits) for a public key
|
||||||
# In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
# In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
||||||
if len(data) != 52:
|
if len(data) != expected_words:
|
||||||
return "Invalid Nostr npub: incorrect length"
|
return "Invalid Nostr npub: incorrect length"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -6,35 +6,18 @@ import { api } from "../../api";
|
||||||
import { sharedStyles } from "../../styles/shared";
|
import { sharedStyles } from "../../styles/shared";
|
||||||
import { Header } from "../../components/Header";
|
import { Header } from "../../components/Header";
|
||||||
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
import constants from "../../../../shared/constants.json";
|
||||||
|
|
||||||
interface InviteRecord {
|
const { READY, SPENT, REVOKED } = constants.inviteStatuses;
|
||||||
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> {
|
// Use generated types from OpenAPI schema
|
||||||
records: T[];
|
type InviteRecord = components["schemas"]["InviteResponse"];
|
||||||
total: number;
|
type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_"];
|
||||||
page: number;
|
type UserOption = components["schemas"]["AdminUserResponse"];
|
||||||
per_page: number;
|
|
||||||
total_pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserOption {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminInvitesPage() {
|
export default function AdminInvitesPage() {
|
||||||
const [data, setData] = useState<PaginatedResponse<InviteRecord> | null>(null);
|
const [data, setData] = useState<PaginatedInvites | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
|
|
@ -63,7 +46,7 @@ export default function AdminInvitesPage() {
|
||||||
if (status) {
|
if (status) {
|
||||||
url += `&status=${status}`;
|
url += `&status=${status}`;
|
||||||
}
|
}
|
||||||
const data = await api.get<PaginatedResponse<InviteRecord>>(url);
|
const data = await api.get<PaginatedInvites>(url);
|
||||||
setData(data);
|
setData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setData(null);
|
setData(null);
|
||||||
|
|
@ -117,11 +100,11 @@ export default function AdminInvitesPage() {
|
||||||
|
|
||||||
const getStatusBadgeStyle = (status: string) => {
|
const getStatusBadgeStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ready":
|
case READY:
|
||||||
return styles.statusReady;
|
return styles.statusReady;
|
||||||
case "spent":
|
case SPENT:
|
||||||
return styles.statusSpent;
|
return styles.statusSpent;
|
||||||
case "revoked":
|
case REVOKED:
|
||||||
return styles.statusRevoked;
|
return styles.statusRevoked;
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -198,9 +181,9 @@ export default function AdminInvitesPage() {
|
||||||
style={styles.filterSelect}
|
style={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="ready">Ready</option>
|
<option value={READY}>Ready</option>
|
||||||
<option value="spent">Spent</option>
|
<option value={SPENT}>Spent</option>
|
||||||
<option value="revoked">Revoked</option>
|
<option value={REVOKED}>Revoked</option>
|
||||||
</select>
|
</select>
|
||||||
<span style={styles.totalCount}>
|
<span style={styles.totalCount}>
|
||||||
{data?.total ?? 0} invites
|
{data?.total ?? 0} invites
|
||||||
|
|
@ -240,7 +223,7 @@ export default function AdminInvitesPage() {
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{record.status === "ready" && (
|
{record.status === READY && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRevoke(record.id)}
|
onClick={() => handleRevoke(record.id)}
|
||||||
style={styles.revokeButton}
|
style={styles.revokeButton}
|
||||||
|
|
|
||||||
|
|
@ -6,35 +6,17 @@ import { api } from "../api";
|
||||||
import { sharedStyles } from "../styles/shared";
|
import { sharedStyles } from "../styles/shared";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
|
import { components } from "../generated/api";
|
||||||
|
|
||||||
interface CounterRecord {
|
// Use generated types from OpenAPI schema
|
||||||
id: number;
|
type CounterRecord = components["schemas"]["CounterRecordResponse"];
|
||||||
user_email: string;
|
type SumRecord = components["schemas"]["SumRecordResponse"];
|
||||||
value_before: number;
|
type PaginatedCounterRecords = components["schemas"]["PaginatedResponse_CounterRecordResponse_"];
|
||||||
value_after: number;
|
type PaginatedSumRecords = components["schemas"]["PaginatedResponse_SumRecordResponse_"];
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SumRecord {
|
|
||||||
id: number;
|
|
||||||
user_email: string;
|
|
||||||
a: number;
|
|
||||||
b: number;
|
|
||||||
result: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
|
||||||
records: T[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
per_page: number;
|
|
||||||
total_pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
const [counterData, setCounterData] = useState<PaginatedResponse<CounterRecord> | null>(null);
|
const [counterData, setCounterData] = useState<PaginatedCounterRecords | null>(null);
|
||||||
const [sumData, setSumData] = useState<PaginatedResponse<SumRecord> | null>(null);
|
const [sumData, setSumData] = useState<PaginatedSumRecords | null>(null);
|
||||||
const [counterError, setCounterError] = useState<string | null>(null);
|
const [counterError, setCounterError] = useState<string | null>(null);
|
||||||
const [sumError, setSumError] = useState<string | null>(null);
|
const [sumError, setSumError] = useState<string | null>(null);
|
||||||
const [counterPage, setCounterPage] = useState(1);
|
const [counterPage, setCounterPage] = useState(1);
|
||||||
|
|
@ -47,7 +29,7 @@ export default function AuditPage() {
|
||||||
const fetchCounterRecords = useCallback(async (page: number) => {
|
const fetchCounterRecords = useCallback(async (page: number) => {
|
||||||
setCounterError(null);
|
setCounterError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<PaginatedResponse<CounterRecord>>(
|
const data = await api.get<PaginatedCounterRecords>(
|
||||||
`/api/audit/counter?page=${page}&per_page=10`
|
`/api/audit/counter?page=${page}&per_page=10`
|
||||||
);
|
);
|
||||||
setCounterData(data);
|
setCounterData(data);
|
||||||
|
|
@ -60,7 +42,7 @@ export default function AuditPage() {
|
||||||
const fetchSumRecords = useCallback(async (page: number) => {
|
const fetchSumRecords = useCallback(async (page: number) => {
|
||||||
setSumError(null);
|
setSumError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<PaginatedResponse<SumRecord>>(
|
const data = await api.get<PaginatedSumRecords>(
|
||||||
`/api/audit/sum?page=${page}&per_page=10`
|
`/api/audit/sum?page=${page}&per_page=10`
|
||||||
);
|
);
|
||||||
setSumData(data);
|
setSumData(data);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
import { api, ApiError } from "./api";
|
import { api, ApiError } from "./api";
|
||||||
|
import { components } from "./generated/api";
|
||||||
|
|
||||||
// Permission constants matching backend
|
// Permission constants - must match backend/models.py Permission enum.
|
||||||
|
// Backend exposes these via GET /api/meta/constants for validation.
|
||||||
|
// TODO: Generate this from the backend endpoint at build time.
|
||||||
export const Permission = {
|
export const Permission = {
|
||||||
VIEW_COUNTER: "view_counter",
|
VIEW_COUNTER: "view_counter",
|
||||||
INCREMENT_COUNTER: "increment_counter",
|
INCREMENT_COUNTER: "increment_counter",
|
||||||
|
|
@ -16,12 +19,8 @@ export const Permission = {
|
||||||
|
|
||||||
export type PermissionType = typeof Permission[keyof typeof Permission];
|
export type PermissionType = typeof Permission[keyof typeof Permission];
|
||||||
|
|
||||||
interface User {
|
// Use generated type from OpenAPI schema
|
||||||
id: number;
|
type User = components["schemas"]["UserResponse"];
|
||||||
email: string;
|
|
||||||
roles: string[];
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { useRouter } from "next/navigation";
|
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";
|
||||||
|
import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
|
const { ADMIN, REGULAR } = constants.roles;
|
||||||
|
|
||||||
type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites";
|
type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites";
|
||||||
|
|
||||||
|
|
@ -33,8 +36,8 @@ const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||||
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 isAdminUser = hasRole(ADMIN);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
|
|
|
||||||
1120
frontend/app/generated/api.ts
Normal file
1120
frontend/app/generated/api.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,19 +5,15 @@ import { api } from "../api";
|
||||||
import { sharedStyles } from "../styles/shared";
|
import { sharedStyles } from "../styles/shared";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
|
import { components } from "../generated/api";
|
||||||
|
import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
interface Invite {
|
// Use generated type from OpenAPI schema
|
||||||
id: number;
|
type Invite = components["schemas"]["UserInviteResponse"];
|
||||||
identifier: string;
|
|
||||||
status: string;
|
|
||||||
used_by_email: string | null;
|
|
||||||
created_at: string;
|
|
||||||
spent_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InvitesPage() {
|
export default function InvitesPage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredRole: "regular",
|
requiredRole: constants.roles.REGULAR,
|
||||||
fallbackRedirect: "/audit",
|
fallbackRedirect: "/audit",
|
||||||
});
|
});
|
||||||
const [invites, setInvites] = useState<Invite[]>([]);
|
const [invites, setInvites] = useState<Invite[]>([]);
|
||||||
|
|
@ -71,9 +67,10 @@ export default function InvitesPage() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyInvites = invites.filter((i) => i.status === "ready");
|
const { READY, SPENT, REVOKED } = constants.inviteStatuses;
|
||||||
const spentInvites = invites.filter((i) => i.status === "spent");
|
const readyInvites = invites.filter((i) => i.status === READY);
|
||||||
const revokedInvites = invites.filter((i) => i.status === "revoked");
|
const spentInvites = invites.filter((i) => i.status === SPENT);
|
||||||
|
const revokedInvites = invites.filter((i) => i.status === REVOKED);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,13 @@ import { api, ApiError } from "../api";
|
||||||
import { sharedStyles } from "../styles/shared";
|
import { sharedStyles } from "../styles/shared";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
|
import { components } from "../generated/api";
|
||||||
|
import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
interface ProfileData {
|
// Use generated type from OpenAPI schema
|
||||||
contact_email: string | null;
|
type ProfileData = components["schemas"]["ProfileResponse"];
|
||||||
telegram: string | null;
|
|
||||||
signal: string | null;
|
|
||||||
nostr_npub: string | null;
|
|
||||||
godfather_email: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// UI-specific types (not from API)
|
||||||
interface FormData {
|
interface FormData {
|
||||||
contact_email: string;
|
contact_email: string;
|
||||||
telegram: string;
|
telegram: string;
|
||||||
|
|
@ -29,7 +27,9 @@ interface FieldErrors {
|
||||||
nostr_npub?: string;
|
nostr_npub?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side validation matching backend rules
|
// Client-side validation using shared rules from constants
|
||||||
|
const { telegram: telegramRules, signal: signalRules, nostrNpub: npubRules } = constants.validation;
|
||||||
|
|
||||||
function validateEmail(value: string): string | undefined {
|
function validateEmail(value: string): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
// More comprehensive email regex that matches email-validator behavior
|
// More comprehensive email regex that matches email-validator behavior
|
||||||
|
|
@ -43,15 +43,15 @@ function validateEmail(value: string): string | undefined {
|
||||||
|
|
||||||
function validateTelegram(value: string): string | undefined {
|
function validateTelegram(value: string): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
if (!value.startsWith("@")) {
|
if (!value.startsWith(telegramRules.mustStartWith)) {
|
||||||
return "Telegram handle must start with @";
|
return `Telegram handle must start with ${telegramRules.mustStartWith}`;
|
||||||
}
|
}
|
||||||
const handle = value.slice(1);
|
const handle = value.slice(1);
|
||||||
if (handle.length < 1) {
|
if (handle.length < 1) {
|
||||||
return "Telegram handle must have at least one character after @";
|
return `Telegram handle must have at least one character after ${telegramRules.mustStartWith}`;
|
||||||
}
|
}
|
||||||
if (handle.length > 32) {
|
if (handle.length > telegramRules.maxLengthAfterAt) {
|
||||||
return "Telegram handle must be at most 32 characters (after @)";
|
return `Telegram handle must be at most ${telegramRules.maxLengthAfterAt} characters (after ${telegramRules.mustStartWith})`;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -61,16 +61,16 @@ function validateSignal(value: string): string | undefined {
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
return "Signal username cannot be empty";
|
return "Signal username cannot be empty";
|
||||||
}
|
}
|
||||||
if (value.length > 64) {
|
if (value.length > signalRules.maxLength) {
|
||||||
return "Signal username must be at most 64 characters";
|
return `Signal username must be at most ${signalRules.maxLength} characters`;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateNostrNpub(value: string): string | undefined {
|
function validateNostrNpub(value: string): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
if (!value.startsWith("npub1")) {
|
if (!value.startsWith(npubRules.prefix)) {
|
||||||
return "Nostr npub must start with 'npub1'";
|
return `Nostr npub must start with '${npubRules.prefix}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -80,7 +80,7 @@ function validateNostrNpub(value: string): string | undefined {
|
||||||
}
|
}
|
||||||
// npub should decode to 32 bytes (256 bits) for a public key
|
// npub should decode to 32 bytes (256 bits) for a public key
|
||||||
// In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
// In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
||||||
if (decoded.words.length !== 52) {
|
if (decoded.words.length !== npubRules.bech32Words) {
|
||||||
return "Invalid Nostr npub: incorrect length";
|
return "Invalid Nostr npub: incorrect length";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -113,7 +113,7 @@ function toFormData(data: ProfileData): FormData {
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
requiredRole: "regular",
|
requiredRole: constants.roles.REGULAR,
|
||||||
fallbackRedirect: "/audit",
|
fallbackRedirect: "/audit",
|
||||||
});
|
});
|
||||||
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
||||||
|
|
@ -152,7 +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);
|
setGodfatherEmail(data.godfather_email ?? null);
|
||||||
} 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" });
|
||||||
|
|
|
||||||
277
frontend/package-lock.json
generated
277
frontend/package-lock.json
generated
|
|
@ -20,6 +20,7 @@
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
"openapi-typescript": "^7.10.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
|
|
@ -1434,6 +1435,52 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@redocly/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/config": {
|
||||||
|
"version": "0.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz",
|
||||||
|
"integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core": {
|
||||||
|
"version": "1.34.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz",
|
||||||
|
"integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/ajv": "^8.11.2",
|
||||||
|
"@redocly/config": "^0.22.0",
|
||||||
|
"colorette": "^1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.5",
|
||||||
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"minimatch": "^5.0.1",
|
||||||
|
"pluralize": "^8.0.0",
|
||||||
|
"yaml-ast-parser": "0.0.43"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0",
|
||||||
|
"npm": ">=9.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -2037,6 +2084,16 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-colors": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
|
@ -2062,6 +2119,13 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
|
|
@ -2083,6 +2147,13 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.10",
|
"version": "2.9.10",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz",
|
||||||
|
|
@ -2099,6 +2170,16 @@
|
||||||
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
|
|
@ -2191,6 +2272,13 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/change-case": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
||||||
|
|
@ -2252,6 +2340,13 @@
|
||||||
"simple-swizzle": "^0.2.2"
|
"simple-swizzle": "^0.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
|
@ -2454,6 +2549,30 @@
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
|
@ -2533,6 +2652,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/index-to-position": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
|
|
@ -2547,6 +2679,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-levenshtein": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -2554,6 +2696,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-yaml": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "26.1.0",
|
"version": "26.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||||
|
|
@ -2607,6 +2762,13 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -2658,6 +2820,19 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -2752,6 +2927,45 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi-typescript": {
|
||||||
|
"version": "7.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz",
|
||||||
|
"integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/openapi-core": "^1.34.5",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"change-case": "^5.4.4",
|
||||||
|
"parse-json": "^8.3.0",
|
||||||
|
"supports-color": "^10.2.2",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openapi-typescript": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse-json": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
"index-to-position": "^1.1.0",
|
||||||
|
"type-fest": "^4.39.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
|
@ -2820,6 +3034,16 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pluralize": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|
@ -2913,6 +3137,16 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.5",
|
"version": "4.53.5",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
||||||
|
|
@ -3112,6 +3346,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|
@ -3215,6 +3462,19 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|
@ -3582,6 +3842,23 @@
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yaml-ast-parser": {
|
||||||
|
"version": "0.0.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"generate-api-types": "openapi-typescript http://localhost:8000/openapi.json -o app/generated/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
"openapi-typescript": "^7.10.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -29,6 +29,12 @@ cd ..
|
||||||
# Wait for backend
|
# Wait for backend
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
|
# Generate API types from OpenAPI schema
|
||||||
|
echo "Generating API types..."
|
||||||
|
cd frontend
|
||||||
|
npm run generate-api-types
|
||||||
|
cd ..
|
||||||
|
|
||||||
# Run tests (suppress Node.js color warnings)
|
# Run tests (suppress Node.js color warnings)
|
||||||
cd frontend
|
cd frontend
|
||||||
NODE_NO_WARNINGS=1 npm run test:e2e
|
NODE_NO_WARNINGS=1 npm run test:e2e
|
||||||
|
|
|
||||||
25
shared/constants.json
Normal file
25
shared/constants.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"roles": {
|
||||||
|
"ADMIN": "admin",
|
||||||
|
"REGULAR": "regular"
|
||||||
|
},
|
||||||
|
"inviteStatuses": {
|
||||||
|
"READY": "ready",
|
||||||
|
"SPENT": "spent",
|
||||||
|
"REVOKED": "revoked"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"telegram": {
|
||||||
|
"maxLengthAfterAt": 32,
|
||||||
|
"mustStartWith": "@"
|
||||||
|
},
|
||||||
|
"signal": {
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"nostrNpub": {
|
||||||
|
"prefix": "npub1",
|
||||||
|
"bech32Words": 52
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue