Compare commits
16 Commits
8be9718293
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c896075ead | ||
| ac0a04e71f | |||
| 94cc74c0fb | |||
| 41a18edb4f | |||
| 291eba63a7 | |||
| d98b0da410 | |||
| af55cdd8a6 | |||
| 0ec1beac8f | |||
| d541b92253 | |||
| d23e24b826 | |||
| e9eccc5b21 | |||
| 79ad7b9133 | |||
| 50ed370d24 | |||
|
|
e279fc76ee | ||
| 177c02006a | |||
| 7a828d7215 |
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-eg7j
|
||||||
|
title: Fix JWT verification failing in local dev (HS256 fallback)
|
||||||
|
status: completed
|
||||||
|
type: bug
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-22T08:37:18Z
|
||||||
|
updated_at: 2026-03-22T08:38:57Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Local GoTrue signs JWTs with HS256, but the JWKS migration only supports RS256. The JWKS endpoint returns empty keys locally, causing 500 errors on all authenticated endpoints. Add HS256 fallback using SUPABASE_JWT_SECRET for local dev.
|
||||||
|
|
||||||
|
## Summary of Changes\n\nAdded HS256 fallback to JWT verification so local GoTrue (which signs with HMAC) works alongside the JWKS/RS256 path used in production. Added `SUPABASE_JWT_SECRET` config setting, passed it in docker-compose.yml, and updated .env.example files.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-f2hs
|
||||||
|
title: Optional TOTP MFA for email/password accounts
|
||||||
|
status: in-progress
|
||||||
|
type: feature
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-21T12:19:18Z
|
||||||
|
updated_at: 2026-03-21T12:56:34Z
|
||||||
|
parent: nuzlocke-tracker-wwnu
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users who sign up with email/password have no MFA option. Google/Discord OAuth users get their provider's MFA, but email-only users have a weaker security posture.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Supabase has built-in TOTP MFA support via the `supabase.auth.mfa` API. This should be optional — users can enable it from their profile/settings page.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- No backend changes needed — Supabase handles MFA enrollment and verification at the auth layer
|
||||||
|
- JWT tokens from MFA-enrolled users include an `aal` (authenticator assurance level) claim; optionally validate `aal2` for sensitive operations in the future
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. Add MFA setup flow to user profile/settings page:
|
||||||
|
- "Enable MFA" button → calls `supabase.auth.mfa.enroll({ factorType: 'totp' })`
|
||||||
|
- Show QR code from enrollment response
|
||||||
|
- Verify with TOTP code → `supabase.auth.mfa.challengeAndVerify()`
|
||||||
|
2. Add MFA challenge during login:
|
||||||
|
- After email/password sign-in, check `supabase.auth.mfa.getAuthenticatorAssuranceLevel()`
|
||||||
|
- If `currentLevel === 'aal1'` and `nextLevel === 'aal2'`, show TOTP input
|
||||||
|
- Verify → `supabase.auth.mfa.challengeAndVerify()`
|
||||||
|
3. Add "Disable MFA" option with re-verification
|
||||||
|
4. Only show MFA options for email/password users (not OAuth)
|
||||||
|
|
||||||
|
### UX
|
||||||
|
- Settings page: toggle to enable/disable MFA
|
||||||
|
- Login flow: TOTP input step after password for enrolled users
|
||||||
|
- Recovery: Supabase provides recovery codes during enrollment — display them
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `frontend/src/pages/` — new MFA settings component or add to existing profile page
|
||||||
|
- `frontend/src/pages/Login.tsx` — add MFA challenge step
|
||||||
|
- `frontend/src/contexts/AuthContext.tsx` — handle AAL levels
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Add MFA enrollment UI (QR code, verification) to profile/settings
|
||||||
|
- [x] Display backup secret code after enrollment (Supabase TOTP doesn't provide recovery codes)
|
||||||
|
- [x] Add TOTP challenge step to login flow
|
||||||
|
- [x] Check AAL after login and redirect to TOTP if needed
|
||||||
|
- [x] Add "Disable MFA" with re-verification
|
||||||
|
- [x] Only show MFA options for email/password users
|
||||||
|
- [ ] Test: full enrollment → login → TOTP flow
|
||||||
|
- [N/A] Test: recovery code works when TOTP unavailable (Supabase doesn't provide recovery codes; users save their secret key instead)
|
||||||
@@ -5,7 +5,7 @@ status: todo
|
|||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-03-21T21:50:48Z
|
created_at: 2026-03-21T21:50:48Z
|
||||||
updated_at: 2026-03-21T22:04:08Z
|
updated_at: 2026-03-22T08:08:13Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-t9aj
|
# nuzlocke-tracker-t9aj
|
||||||
title: Migrate JWT verification from HS256 shared secret to asymmetric keys (JWKS)
|
title: Migrate JWT verification from HS256 shared secret to asymmetric keys (JWKS)
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
priority: low
|
priority: low
|
||||||
created_at: 2026-03-21T11:14:29Z
|
created_at: 2026-03-21T11:14:29Z
|
||||||
updated_at: 2026-03-21T13:01:46Z
|
updated_at: 2026-03-21T13:01:33Z
|
||||||
---
|
---
|
||||||
|
|
||||||
The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://<project>.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [ ] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [ ] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [ ] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [ ] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts
|
The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://<project>.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [x] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [x] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [x] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [x] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts
|
||||||
|
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
- Added `cryptography==45.0.3` dependency for RS256 support
|
||||||
|
- Updated `auth.py` to use `PyJWKClient` for fetching and caching JWKS public keys from `{SUPABASE_URL}/.well-known/jwks.json`
|
||||||
|
- Changed JWT verification from HS256 to RS256
|
||||||
|
- Removed `supabase_jwt_secret` from config.py
|
||||||
|
- Updated docker-compose.yml: removed `SUPABASE_JWT_SECRET`, backend now uses JWKS from GoTrue URL
|
||||||
|
- Updated docker-compose.prod.yml: replaced `SUPABASE_JWT_SECRET` with `SUPABASE_URL`
|
||||||
|
- Updated deploy.yml: deploy workflow now writes `SUPABASE_URL` instead of `SUPABASE_JWT_SECRET`
|
||||||
|
- Updated .env.example files: removed `SUPABASE_JWT_SECRET` references
|
||||||
|
- Rewrote tests to use RS256 tokens with mocked JWKS client
|
||||||
|
|
||||||
|
**Note:** For production, add `SUPABASE_URL` to your GitHub secrets (should point to your Supabase project URL like `https://your-project.supabase.co`).
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke
|
||||||
|
|
||||||
# Supabase Auth (backend)
|
# Supabase Auth (backend uses JWKS from this URL for JWT verification)
|
||||||
# For local dev with GoTrue container:
|
# For local dev with GoTrue container:
|
||||||
SUPABASE_URL=http://localhost:9999
|
SUPABASE_URL=http://localhost:9999
|
||||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0MDQwNjEzLCJleHAiOjIwODk0MDA2MTN9.EV6tRj7gLqoiT-l2vDFw_67myqRjwpcZTuRb3Xs1nr4
|
# HS256 fallback for local GoTrue (not needed for Supabase Cloud):
|
||||||
SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
||||||
|
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0MDQwNjEzLCJleHAiOjIwODk0MDA2MTN9.EV6tRj7gLqoiT-l2vDFw_67myqRjwpcZTuRb3Xs1nr4
|
||||||
# For production, replace with your Supabase cloud values:
|
# For production, replace with your Supabase cloud values:
|
||||||
# SUPABASE_URL=https://your-project.supabase.co
|
# SUPABASE_URL=https://your-project.supabase.co
|
||||||
# SUPABASE_ANON_KEY=your-anon-key
|
# SUPABASE_ANON_KEY=your-anon-key
|
||||||
# SUPABASE_JWT_SECRET=your-jwt-secret
|
|
||||||
|
|
||||||
# Frontend settings (used by Vite)
|
# Frontend settings (used by Vite)
|
||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
# Write .env from secrets (overwrites any existing file)
|
# Write .env from secrets (overwrites any existing file)
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \
|
"POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \
|
||||||
"SUPABASE_JWT_SECRET=${{ secrets.SUPABASE_JWT_SECRET }}" \
|
"SUPABASE_URL=${{ secrets.SUPABASE_URL }}" \
|
||||||
| $SSH_CMD "cat > '${DEPLOY_DIR}/.env'"
|
| $SSH_CMD "cat > '${DEPLOY_DIR}/.env'"
|
||||||
|
|
||||||
$SCP_CMD docker-compose.prod.yml "root@192.168.1.10:${DEPLOY_DIR}/docker-compose.yml"
|
$SCP_CMD docker-compose.prod.yml "root@192.168.1.10:${DEPLOY_DIR}/docker-compose.yml"
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ API_V1_PREFIX="/api/v1"
|
|||||||
# Database settings
|
# Database settings
|
||||||
DATABASE_URL="sqlite:///./nuzlocke.db"
|
DATABASE_URL="sqlite:///./nuzlocke.db"
|
||||||
|
|
||||||
# Supabase Auth
|
# Supabase Auth (JWKS used for JWT verification)
|
||||||
SUPABASE_URL=https://your-project.supabase.co
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
SUPABASE_ANON_KEY=your-anon-key
|
SUPABASE_ANON_KEY=your-anon-key
|
||||||
SUPABASE_JWT_SECRET=your-jwt-secret
|
# HS256 fallback for local GoTrue (not needed for Supabase Cloud):
|
||||||
|
# SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"asyncpg==0.31.0",
|
"asyncpg==0.31.0",
|
||||||
"alembic==1.18.4",
|
"alembic==1.18.4",
|
||||||
"PyJWT==2.12.1",
|
"PyJWT==2.12.1",
|
||||||
|
"cryptography==45.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from jwt import PyJWKClient, PyJWKClientError, PyJWKSetError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ from app.core.database import get_session
|
|||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
_jwks_client: PyJWKClient | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AuthUser:
|
class AuthUser:
|
||||||
@@ -21,6 +24,15 @@ class AuthUser:
|
|||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwks_client() -> PyJWKClient | None:
|
||||||
|
"""Get or create a cached JWKS client."""
|
||||||
|
global _jwks_client
|
||||||
|
if _jwks_client is None and settings.supabase_url:
|
||||||
|
jwks_url = f"{settings.supabase_url.rstrip('/')}/.well-known/jwks.json"
|
||||||
|
_jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300)
|
||||||
|
return _jwks_client
|
||||||
|
|
||||||
|
|
||||||
def _extract_token(request: Request) -> str | None:
|
def _extract_token(request: Request) -> str | None:
|
||||||
"""Extract Bearer token from Authorization header."""
|
"""Extract Bearer token from Authorization header."""
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
@@ -32,24 +44,42 @@ def _extract_token(request: Request) -> str | None:
|
|||||||
return parts[1]
|
return parts[1]
|
||||||
|
|
||||||
|
|
||||||
def _verify_jwt(token: str) -> dict | None:
|
def _verify_jwt_hs256(token: str) -> dict | None:
|
||||||
"""Verify JWT against Supabase JWT secret. Returns payload or None."""
|
"""Verify JWT using HS256 shared secret. Returns payload or None."""
|
||||||
if not settings.supabase_jwt_secret:
|
if not settings.supabase_jwt_secret:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
return jwt.decode(
|
||||||
token,
|
token,
|
||||||
settings.supabase_jwt_secret,
|
settings.supabase_jwt_secret,
|
||||||
algorithms=["HS256"],
|
algorithms=["HS256"],
|
||||||
audience="authenticated",
|
audience="authenticated",
|
||||||
)
|
)
|
||||||
return payload
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_jwt(token: str) -> dict | None:
|
||||||
|
"""Verify JWT using JWKS (RS256), falling back to HS256 shared secret."""
|
||||||
|
client = _get_jwks_client()
|
||||||
|
if client:
|
||||||
|
try:
|
||||||
|
signing_key = client.get_signing_key_from_jwt(token)
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience="authenticated",
|
||||||
|
)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
pass
|
||||||
|
except PyJWKClientError:
|
||||||
|
pass
|
||||||
|
except PyJWKSetError:
|
||||||
|
pass
|
||||||
|
return _verify_jwt_hs256(token)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> AuthUser | None:
|
def get_current_user(request: Request) -> AuthUser | None:
|
||||||
"""
|
"""
|
||||||
Extract and verify the current user from the request.
|
Extract and verify the current user from the request.
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import time
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
|
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
|
||||||
from app.core.config import settings
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(scope="module")
|
||||||
def jwt_secret():
|
def rsa_key_pair():
|
||||||
"""Provide a test JWT secret."""
|
"""Generate RSA key pair for testing."""
|
||||||
return "test-jwt-secret-for-testing-only"
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
return private_key, public_key
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_token(jwt_secret):
|
def valid_token(rsa_key_pair):
|
||||||
"""Generate a valid JWT token."""
|
"""Generate a valid RS256 JWT token."""
|
||||||
|
private_key, _ = rsa_key_pair
|
||||||
payload = {
|
payload = {
|
||||||
"sub": "user-123",
|
"sub": "user-123",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
@@ -27,12 +31,13 @@ def valid_token(jwt_secret):
|
|||||||
"aud": "authenticated",
|
"aud": "authenticated",
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
return jwt.encode(payload, private_key, algorithm="RS256")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def expired_token(jwt_secret):
|
def expired_token(rsa_key_pair):
|
||||||
"""Generate an expired JWT token."""
|
"""Generate an expired RS256 JWT token."""
|
||||||
|
private_key, _ = rsa_key_pair
|
||||||
payload = {
|
payload = {
|
||||||
"sub": "user-123",
|
"sub": "user-123",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
@@ -40,12 +45,13 @@ def expired_token(jwt_secret):
|
|||||||
"aud": "authenticated",
|
"aud": "authenticated",
|
||||||
"exp": int(time.time()) - 3600, # Expired 1 hour ago
|
"exp": int(time.time()) - 3600, # Expired 1 hour ago
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
return jwt.encode(payload, private_key, algorithm="RS256")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def invalid_token():
|
def invalid_token():
|
||||||
"""Generate a token signed with wrong secret."""
|
"""Generate a token signed with wrong key."""
|
||||||
|
wrong_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": "user-123",
|
"sub": "user-123",
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
@@ -53,81 +59,76 @@ def invalid_token():
|
|||||||
"aud": "authenticated",
|
"aud": "authenticated",
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, "wrong-secret", algorithm="HS256")
|
return jwt.encode(payload, wrong_key, algorithm="RS256")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_client(db_session, jwt_secret, valid_token, monkeypatch):
|
def mock_jwks_client(rsa_key_pair):
|
||||||
"""Client with valid auth token and configured JWT secret."""
|
"""Create a mock JWKS client that returns our test public key."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
_, public_key = rsa_key_pair
|
||||||
|
mock_client = MagicMock()
|
||||||
async def _get_client():
|
mock_signing_key = MagicMock()
|
||||||
async with AsyncClient(
|
mock_signing_key.key = public_key
|
||||||
transport=ASGITransport(app=app),
|
mock_client.get_signing_key_from_jwt.return_value = mock_signing_key
|
||||||
base_url="http://test",
|
return mock_client
|
||||||
headers={"Authorization": f"Bearer {valid_token}"},
|
|
||||||
) as ac:
|
|
||||||
yield ac
|
|
||||||
|
|
||||||
return _get_client
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_current_user_valid_token(jwt_secret, valid_token, monkeypatch):
|
async def test_get_current_user_valid_token(valid_token, mock_jwks_client):
|
||||||
"""Test get_current_user returns user for valid token."""
|
"""Test get_current_user returns user for valid token."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
headers = {"Authorization": f"Bearer {valid_token}"}
|
headers = {"Authorization": f"Bearer {valid_token}"}
|
||||||
|
|
||||||
user = get_current_user(MockRequest())
|
user = get_current_user(MockRequest())
|
||||||
assert user is not None
|
assert user is not None
|
||||||
assert user.id == "user-123"
|
assert user.id == "user-123"
|
||||||
assert user.email == "test@example.com"
|
assert user.email == "test@example.com"
|
||||||
assert user.role == "authenticated"
|
assert user.role == "authenticated"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_current_user_no_token(jwt_secret, monkeypatch):
|
async def test_get_current_user_no_token(mock_jwks_client):
|
||||||
"""Test get_current_user returns None when no token."""
|
"""Test get_current_user returns None when no token."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
user = get_current_user(MockRequest())
|
user = get_current_user(MockRequest())
|
||||||
assert user is None
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch):
|
async def test_get_current_user_expired_token(expired_token, mock_jwks_client):
|
||||||
"""Test get_current_user returns None for expired token."""
|
"""Test get_current_user returns None for expired token."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
headers = {"Authorization": f"Bearer {expired_token}"}
|
headers = {"Authorization": f"Bearer {expired_token}"}
|
||||||
|
|
||||||
user = get_current_user(MockRequest())
|
user = get_current_user(MockRequest())
|
||||||
assert user is None
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch):
|
async def test_get_current_user_invalid_token(invalid_token, mock_jwks_client):
|
||||||
"""Test get_current_user returns None for invalid token."""
|
"""Test get_current_user returns None for invalid token."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
headers = {"Authorization": f"Bearer {invalid_token}"}
|
headers = {"Authorization": f"Bearer {invalid_token}"}
|
||||||
|
|
||||||
user = get_current_user(MockRequest())
|
user = get_current_user(MockRequest())
|
||||||
assert user is None
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
async def test_get_current_user_malformed_header(jwt_secret, monkeypatch):
|
async def test_get_current_user_malformed_header(mock_jwks_client):
|
||||||
"""Test get_current_user returns None for malformed auth header."""
|
"""Test get_current_user returns None for malformed auth header."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
headers = {"Authorization": "NotBearer token"}
|
headers = {"Authorization": "NotBearer token"}
|
||||||
|
|
||||||
user = get_current_user(MockRequest())
|
user = get_current_user(MockRequest())
|
||||||
assert user is None
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
async def test_require_auth_valid_user():
|
async def test_require_auth_valid_user():
|
||||||
@@ -158,17 +159,16 @@ async def test_protected_endpoint_without_token(db_session):
|
|||||||
|
|
||||||
|
|
||||||
async def test_protected_endpoint_with_expired_token(
|
async def test_protected_endpoint_with_expired_token(
|
||||||
db_session, jwt_secret, expired_token, monkeypatch
|
db_session, expired_token, mock_jwks_client
|
||||||
):
|
):
|
||||||
"""Test that write endpoint returns 401 with expired token."""
|
"""Test that write endpoint returns 401 with expired token."""
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
async with AsyncClient(
|
||||||
async with AsyncClient(
|
transport=ASGITransport(app=app),
|
||||||
transport=ASGITransport(app=app),
|
base_url="http://test",
|
||||||
base_url="http://test",
|
headers={"Authorization": f"Bearer {expired_token}"},
|
||||||
headers={"Authorization": f"Bearer {expired_token}"},
|
) as ac:
|
||||||
) as ac:
|
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
||||||
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ async def test_require_admin_user_not_in_db(db_session):
|
|||||||
|
|
||||||
|
|
||||||
async def test_admin_endpoint_returns_403_for_non_admin(
|
async def test_admin_endpoint_returns_403_for_non_admin(
|
||||||
db_session, jwt_secret, monkeypatch
|
db_session, rsa_key_pair, mock_jwks_client
|
||||||
):
|
):
|
||||||
"""Test that admin endpoint returns 403 for authenticated non-admin user."""
|
"""Test that admin endpoint returns 403 for authenticated non-admin user."""
|
||||||
user_id = "44444444-4444-4444-4444-444444444444"
|
user_id = "44444444-4444-4444-4444-444444444444"
|
||||||
@@ -243,7 +243,7 @@ async def test_admin_endpoint_returns_403_for_non_admin(
|
|||||||
db_session.add(regular_user)
|
db_session.add(regular_user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
private_key, _ = rsa_key_pair
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
{
|
{
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
@@ -252,30 +252,33 @@ async def test_admin_endpoint_returns_403_for_non_admin(
|
|||||||
"aud": "authenticated",
|
"aud": "authenticated",
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
},
|
},
|
||||||
jwt_secret,
|
private_key,
|
||||||
algorithm="HS256",
|
algorithm="RS256",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncClient(
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
transport=ASGITransport(app=app),
|
async with AsyncClient(
|
||||||
base_url="http://test",
|
transport=ASGITransport(app=app),
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
base_url="http://test",
|
||||||
) as ac:
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
response = await ac.post(
|
) as ac:
|
||||||
"/games",
|
response = await ac.post(
|
||||||
json={
|
"/games",
|
||||||
"name": "Test Game",
|
json={
|
||||||
"slug": "test-game",
|
"name": "Test Game",
|
||||||
"generation": 1,
|
"slug": "test-game",
|
||||||
"region": "Kanto",
|
"generation": 1,
|
||||||
"category": "core",
|
"region": "Kanto",
|
||||||
},
|
"category": "core",
|
||||||
)
|
},
|
||||||
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json()["detail"] == "Admin access required"
|
assert response.json()["detail"] == "Admin access required"
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeypatch):
|
async def test_admin_endpoint_succeeds_for_admin(
|
||||||
|
db_session, rsa_key_pair, mock_jwks_client
|
||||||
|
):
|
||||||
"""Test that admin endpoint succeeds for authenticated admin user."""
|
"""Test that admin endpoint succeeds for authenticated admin user."""
|
||||||
user_id = "55555555-5555-5555-5555-555555555555"
|
user_id = "55555555-5555-5555-5555-555555555555"
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
@@ -286,7 +289,7 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp
|
|||||||
db_session.add(admin_user)
|
db_session.add(admin_user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
private_key, _ = rsa_key_pair
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
{
|
{
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
@@ -295,24 +298,25 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp
|
|||||||
"aud": "authenticated",
|
"aud": "authenticated",
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
},
|
},
|
||||||
jwt_secret,
|
private_key,
|
||||||
algorithm="HS256",
|
algorithm="RS256",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncClient(
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
transport=ASGITransport(app=app),
|
async with AsyncClient(
|
||||||
base_url="http://test",
|
transport=ASGITransport(app=app),
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
base_url="http://test",
|
||||||
) as ac:
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
response = await ac.post(
|
) as ac:
|
||||||
"/games",
|
response = await ac.post(
|
||||||
json={
|
"/games",
|
||||||
"name": "Test Game",
|
json={
|
||||||
"slug": "test-game",
|
"name": "Test Game",
|
||||||
"generation": 1,
|
"slug": "test-game",
|
||||||
"region": "Kanto",
|
"generation": 1,
|
||||||
"category": "core",
|
"region": "Kanto",
|
||||||
},
|
"category": "core",
|
||||||
)
|
},
|
||||||
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert response.json()["name"] == "Test Game"
|
assert response.json()["name"] == "Test Game"
|
||||||
|
|||||||
79
backend/uv.lock
generated
79
backend/uv.lock
generated
@@ -41,6 +41,7 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -63,6 +64,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = "==1.18.4" },
|
{ name = "alembic", specifier = "==1.18.4" },
|
||||||
{ name = "asyncpg", specifier = "==0.31.0" },
|
{ name = "asyncpg", specifier = "==0.31.0" },
|
||||||
|
{ name = "cryptography", specifier = "==45.0.3" },
|
||||||
{ name = "fastapi", specifier = "==0.135.1" },
|
{ name = "fastapi", specifier = "==0.135.1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
|
||||||
{ name = "pydantic", specifier = "==2.12.5" },
|
{ name = "pydantic", specifier = "==2.12.5" },
|
||||||
@@ -123,6 +125,39 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
@@ -144,6 +179,41 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "45.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.1"
|
version = "0.135.1"
|
||||||
@@ -315,6 +385,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/nuzlocke
|
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/nuzlocke
|
||||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DEBUG=true
|
- DEBUG=true
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/nuzlocke
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/nuzlocke
|
||||||
# Auth - must match GoTrue's JWT secret
|
# Auth - uses JWKS from GoTrue for JWT verification, with HS256 fallback
|
||||||
- SUPABASE_URL=http://gotrue:9999
|
- SUPABASE_URL=http://gotrue:9999
|
||||||
- SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
- SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -13,7 +13,7 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "5.91.3",
|
"@tanstack/react-query": "5.94.5",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -1817,9 +1817,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.91.2",
|
"version": "5.94.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.94.5.tgz",
|
||||||
"integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
|
"integrity": "sha512-Vx1JJiBURW/wdNGP45afjrqn0LfxYwL7K/bSrQvNRtyLGF1bxQPgUXCpzscG29e+UeFOh9hz1KOVala0N+bZiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1827,12 +1827,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.91.3",
|
"version": "5.94.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.94.5.tgz",
|
||||||
"integrity": "sha512-D8jsCexxS5crZxAeiH6VlLHOUzmHOxeW5c11y8rZu0c34u/cy18hUKQXA/gn1Ila3ZIFzP+Pzv76YnliC0EtZQ==",
|
"integrity": "sha512-1wmrxKFkor+q8l+ygdHmv0Sq5g84Q3p4xvuJ7AdSIAhQQ7udOt+ZSZ19g1Jea3mHqtlTslLGJsmC4vHFgP0P3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.91.2"
|
"@tanstack/query-core": "5.94.5"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "5.91.3",
|
"@tanstack/react-query": "5.94.5",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
NewRun,
|
NewRun,
|
||||||
RunList,
|
RunList,
|
||||||
RunEncounters,
|
RunEncounters,
|
||||||
|
Settings,
|
||||||
Signup,
|
Signup,
|
||||||
Stats,
|
Stats,
|
||||||
} from './pages'
|
} from './pages'
|
||||||
@@ -42,6 +43,7 @@ function App() {
|
|||||||
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
|
<Route path="genlockes/new" element={<ProtectedRoute><NewGenlocke /></ProtectedRoute>} />
|
||||||
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
||||||
<Route path="stats" element={<Stats />} />
|
<Route path="stats" element={<Stats />} />
|
||||||
|
<Route path="settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
<Route
|
<Route
|
||||||
path="runs/:runId/encounters"
|
path="runs/:runId/encounters"
|
||||||
element={<Navigate to=".." relative="path" replace />}
|
element={<Navigate to=".." relative="path" replace />}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ function ThemeToggle() {
|
|||||||
function UserMenu({ onAction }: { onAction?: () => void }) {
|
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||||
const { user, loading, signOut } = useAuth()
|
const { user, loading, signOut } = useAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||||
@@ -106,6 +107,17 @@ function UserMenu({ onAction }: { onAction?: () => void }) {
|
|||||||
<p className="text-sm text-text-primary truncate">{email}</p>
|
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onAction?.()
|
||||||
|
navigate('/settings')
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import type { User, Session, AuthError } from '@supabase/supabase-js'
|
import type { User, Session, AuthError, Factor } from '@supabase/supabase-js'
|
||||||
import { supabase } from '../lib/supabase'
|
import { supabase } from '../lib/supabase'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
|
||||||
@@ -10,19 +10,42 @@ interface UserProfile {
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MfaState {
|
||||||
|
requiresMfa: boolean
|
||||||
|
factorId: string | null
|
||||||
|
enrolledFactors: Factor[]
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
session: Session | null
|
session: Session | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
mfa: MfaState
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MfaEnrollResult {
|
||||||
|
factorId: string
|
||||||
|
qrCode: string
|
||||||
|
secret: string
|
||||||
|
recoveryCodes?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue extends AuthState {
|
interface AuthContextValue extends AuthState {
|
||||||
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
signInWithEmail: (
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
) => Promise<{ error: AuthError | null; requiresMfa?: boolean }>
|
||||||
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||||
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
||||||
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
|
verifyMfa: (code: string) => Promise<{ error: AuthError | null }>
|
||||||
|
enrollMfa: () => Promise<{ data: MfaEnrollResult | null; error: AuthError | null }>
|
||||||
|
verifyMfaEnrollment: (factorId: string, code: string) => Promise<{ error: AuthError | null }>
|
||||||
|
unenrollMfa: (factorId: string) => Promise<{ error: AuthError | null }>
|
||||||
|
isOAuthUser: boolean
|
||||||
|
refreshMfaState: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -37,25 +60,49 @@ async function syncUserProfile(session: Session | null): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMfaState(): Promise<MfaState> {
|
||||||
|
const defaultState: MfaState = { requiresMfa: false, factorId: null, enrolledFactors: [] }
|
||||||
|
try {
|
||||||
|
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||||
|
if (!aalData) return defaultState
|
||||||
|
|
||||||
|
const { data: factorsData } = await supabase.auth.mfa.listFactors()
|
||||||
|
const verifiedFactors = factorsData?.totp?.filter((f) => f.status === 'verified') ?? []
|
||||||
|
|
||||||
|
const requiresMfa = aalData.currentLevel === 'aal1' && aalData.nextLevel === 'aal2'
|
||||||
|
const factorId = requiresMfa ? (verifiedFactors[0]?.id ?? null) : null
|
||||||
|
|
||||||
|
return { requiresMfa, factorId, enrolledFactors: verifiedFactors }
|
||||||
|
} catch {
|
||||||
|
return defaultState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [state, setState] = useState<AuthState>({
|
const [state, setState] = useState<AuthState>({
|
||||||
user: null,
|
user: null,
|
||||||
session: null,
|
session: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
mfa: { requiresMfa: false, factorId: null, enrolledFactors: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshMfaState = useCallback(async () => {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||||
const isAdmin = await syncUserProfile(session)
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
const isAdmin = await syncUserProfile(session)
|
const [isAdmin, mfa] = await Promise.all([syncUserProfile(session), getMfaState()])
|
||||||
setState({ user: session?.user ?? null, session, loading: false, isAdmin })
|
setState({ user: session?.user ?? null, session, loading: false, isAdmin, mfa })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
@@ -63,7 +110,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
return { error }
|
if (error) return { error }
|
||||||
|
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
return { error: null, requiresMfa: mfa.requiresMfa }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
||||||
@@ -91,6 +142,79 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await supabase.auth.signOut()
|
await supabase.auth.signOut()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const verifyMfa = useCallback(
|
||||||
|
async (code: string) => {
|
||||||
|
const factorId = state.mfa.factorId
|
||||||
|
if (!factorId) {
|
||||||
|
return { error: { message: 'No MFA factor found' } as AuthError }
|
||||||
|
}
|
||||||
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||||
|
factorId,
|
||||||
|
})
|
||||||
|
if (challengeError) return { error: challengeError }
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.mfa.verify({
|
||||||
|
factorId,
|
||||||
|
challengeId: challengeData.id,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
},
|
||||||
|
[state.mfa.factorId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const enrollMfa = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
|
||||||
|
if (error || !data) {
|
||||||
|
return { data: null, error }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
factorId: data.id,
|
||||||
|
qrCode: data.totp.qr_code,
|
||||||
|
secret: data.totp.secret,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const verifyMfaEnrollment = useCallback(async (factorId: string, code: string) => {
|
||||||
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||||
|
factorId,
|
||||||
|
})
|
||||||
|
if (challengeError) return { error: challengeError }
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.mfa.verify({
|
||||||
|
factorId,
|
||||||
|
challengeId: challengeData.id,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const unenrollMfa = useCallback(async (factorId: string) => {
|
||||||
|
const { error } = await supabase.auth.mfa.unenroll({ factorId })
|
||||||
|
if (!error) {
|
||||||
|
const mfa = await getMfaState()
|
||||||
|
setState((prev) => ({ ...prev, mfa }))
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOAuthUser = useMemo(() => {
|
||||||
|
if (!state.user) return false
|
||||||
|
const provider = state.user.app_metadata?.['provider']
|
||||||
|
return provider === 'google' || provider === 'discord'
|
||||||
|
}, [state.user])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -99,8 +223,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signInWithGoogle,
|
signInWithGoogle,
|
||||||
signInWithDiscord,
|
signInWithDiscord,
|
||||||
signOut,
|
signOut,
|
||||||
|
verifyMfa,
|
||||||
|
enrollMfa,
|
||||||
|
verifyMfaEnrollment,
|
||||||
|
unenrollMfa,
|
||||||
|
isOAuthUser,
|
||||||
|
refreshMfaState,
|
||||||
}),
|
}),
|
||||||
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
|
[
|
||||||
|
state,
|
||||||
|
signInWithEmail,
|
||||||
|
signUpWithEmail,
|
||||||
|
signInWithGoogle,
|
||||||
|
signInWithDiscord,
|
||||||
|
signOut,
|
||||||
|
verifyMfa,
|
||||||
|
enrollMfa,
|
||||||
|
verifyMfaEnrollment,
|
||||||
|
unenrollMfa,
|
||||||
|
isOAuthUser,
|
||||||
|
refreshMfaState,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const isLocalDev = import.meta.env['VITE_SUPABASE_URL']?.includes('localhost') ?
|
|||||||
export function Login() {
|
export function Login() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
|
const [showMfaChallenge, setShowMfaChallenge] = useState(false)
|
||||||
|
const { signInWithEmail, signInWithGoogle, signInWithDiscord, verifyMfa } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@@ -20,11 +22,29 @@ export function Login() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const { error } = await signInWithEmail(email, password)
|
const { error, requiresMfa } = await signInWithEmail(email, password)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(error.message)
|
setError(error.message)
|
||||||
|
} else if (requiresMfa) {
|
||||||
|
setShowMfaChallenge(true)
|
||||||
|
} else {
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMfaSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const { error } = await verifyMfa(totpCode)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
setTotpCode('')
|
||||||
} else {
|
} else {
|
||||||
navigate(from, { replace: true })
|
navigate(from, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -42,6 +62,68 @@ export function Login() {
|
|||||||
if (error) setError(error.message)
|
if (error) setError(error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMfaChallenge) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Two-Factor Authentication</h1>
|
||||||
|
<p className="text-text-secondary mt-1">Enter the code from your authenticator app</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleMfaSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="totp-code"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
|
Authentication code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={totpCode.length !== 6 || loading}
|
||||||
|
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Verifying...' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMfaChallenge(false)
|
||||||
|
setTotpCode('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-sm text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
|||||||
303
frontend/src/pages/Settings.tsx
Normal file
303
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
type MfaStep = 'idle' | 'enrolling' | 'verifying' | 'success' | 'disabling'
|
||||||
|
|
||||||
|
interface EnrollmentData {
|
||||||
|
factorId: string
|
||||||
|
qrCode: string
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const { user, loading, mfa, isOAuthUser, enrollMfa, verifyMfaEnrollment, unenrollMfa } = useAuth()
|
||||||
|
const [step, setStep] = useState<MfaStep>('idle')
|
||||||
|
const [enrollmentData, setEnrollmentData] = useState<EnrollmentData | null>(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
const [disableFactorId, setDisableFactorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-4 border-accent-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMfa = mfa.enrolledFactors.length > 0
|
||||||
|
|
||||||
|
async function handleEnroll() {
|
||||||
|
setError(null)
|
||||||
|
setActionLoading(true)
|
||||||
|
const { data, error: enrollError } = await enrollMfa()
|
||||||
|
setActionLoading(false)
|
||||||
|
|
||||||
|
if (enrollError || !data) {
|
||||||
|
setError(enrollError?.message ?? 'Failed to start MFA enrollment')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnrollmentData(data)
|
||||||
|
setStep('enrolling')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyEnrollment(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!enrollmentData) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setActionLoading(true)
|
||||||
|
const { error: verifyError } = await verifyMfaEnrollment(enrollmentData.factorId, code)
|
||||||
|
setActionLoading(false)
|
||||||
|
|
||||||
|
if (verifyError) {
|
||||||
|
setError(verifyError.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('success')
|
||||||
|
setCode('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartDisable(factorId: string) {
|
||||||
|
setDisableFactorId(factorId)
|
||||||
|
setStep('disabling')
|
||||||
|
setCode('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmDisable(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!disableFactorId) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setActionLoading(true)
|
||||||
|
const { error: unenrollError } = await unenrollMfa(disableFactorId)
|
||||||
|
setActionLoading(false)
|
||||||
|
|
||||||
|
if (unenrollError) {
|
||||||
|
setError(unenrollError.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('idle')
|
||||||
|
setDisableFactorId(null)
|
||||||
|
setCode('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setStep('idle')
|
||||||
|
setEnrollmentData(null)
|
||||||
|
setDisableFactorId(null)
|
||||||
|
setCode('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<section className="bg-surface-1 rounded-lg border border-border-default p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Two-Factor Authentication</h2>
|
||||||
|
|
||||||
|
{isOAuthUser ? (
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
You signed in with an OAuth provider (Google/Discord). MFA is managed by your provider.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{step === 'idle' && (
|
||||||
|
<>
|
||||||
|
{hasMfa ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">MFA is enabled</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Your account is protected with two-factor authentication.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartDisable(mfa.enrolledFactors[0]?.id ?? '')}
|
||||||
|
className="px-4 py-2 border border-red-500/50 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
Disable MFA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Add an extra layer of security to your account by enabling two-factor
|
||||||
|
authentication with an authenticator app.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEnroll}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Setting up...' : 'Enable MFA'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'enrolling' && enrollmentData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
|
<img src={enrollmentData.qrCode} alt="MFA QR Code" className="w-48 h-48" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-2 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
Manual entry code (save this as a backup):
|
||||||
|
</p>
|
||||||
|
<code className="block text-sm font-mono bg-surface-3 px-3 py-2 rounded select-all text-center break-all">
|
||||||
|
{enrollmentData.secret}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-yellow-500">
|
||||||
|
Save this code securely. You can use it to restore your authenticator if you
|
||||||
|
lose access to your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleVerifyEnrollment} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="totp-code"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
|
Enter the 6-digit code from your app
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={code.length !== 6 || actionLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Verifying...' : 'Verify & Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-semibold">MFA enabled successfully!</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
Your account is now protected with two-factor authentication. You'll need to
|
||||||
|
enter a code from your authenticator app each time you sign in.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('idle')}
|
||||||
|
className="px-4 py-2 bg-accent-600 hover:bg-accent-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'disabling' && (
|
||||||
|
<form onSubmit={handleConfirmDisable} className="space-y-4">
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
To disable MFA, enter a code from your authenticator app to confirm.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="disable-code"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
|
Authentication code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="disable-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 text-center text-lg tracking-widest font-mono"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={code.length !== 6 || actionLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Disabling...' : 'Disable MFA'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-border-default rounded-lg hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@ export { NewGenlocke } from './NewGenlocke'
|
|||||||
export { NewRun } from './NewRun'
|
export { NewRun } from './NewRun'
|
||||||
export { RunList } from './RunList'
|
export { RunList } from './RunList'
|
||||||
export { RunEncounters } from './RunEncounters'
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
export { Settings } from './Settings'
|
||||||
export { Signup } from './Signup'
|
export { Signup } from './Signup'
|
||||||
export { Stats } from './Stats'
|
export { Stats } from './Stats'
|
||||||
|
|||||||
Reference in New Issue
Block a user