diff --git a/modern/.github/workflows/ci.yml b/modern/.github/workflows/ci.yml index 2febfc2..d014e38 100644 --- a/modern/.github/workflows/ci.yml +++ b/modern/.github/workflows/ci.yml @@ -6,6 +6,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Check Python syntax - run: python -m py_compile modern/backend/server.py - - name: Check frontend package.json - run: cat modern/frontend/package.json + run: python -m py_compile modern/backend/*.py + - name: Run tests + run: | + python -m pip install -r modern/backend/requirements_full.txt + pytest -q diff --git a/modern/backend/app.py b/modern/backend/app.py index 28e6c11..ee31530 100644 --- a/modern/backend/app.py +++ b/modern/backend/app.py @@ -18,6 +18,18 @@ app.add_middleware( allow_headers=["*"], ) + +# HTTPS enforcement middleware (for production behind a proxy, check X-Forwarded-Proto) +@app.middleware('http') +async def https_redirect(request, call_next): + if os.getenv('FORCE_HTTPS', 'false').lower() == 'true': + proto = request.headers.get('x-forwarded-proto', request.url.scheme) + if proto != 'https': + from starlette.responses import RedirectResponse + url = request.url.replace(scheme='https') + return RedirectResponse(str(url)) + return await call_next(request) + @app.on_event('startup') def startup_event(): models.init_db() @@ -198,13 +210,16 @@ def list_integrations(): @app.delete('/api/v1/integrations/{integration_id}') -def delete_integration(integration_id: int): +def delete_integration(integration_id: int, request=None): db = models.SessionLocal() try: row = db.query(models.Integration).filter_by(id=integration_id).first() if not row: raise HTTPException(status_code=404, detail='integration not found') - db.delete(row) + # require owner or admin + from .rbac import require_owner_or_admin + require_owner_or_admin(row.user_id)(request) + db.delete(row) db.commit() return {'ok': True} finally: @@ -223,7 +238,10 @@ def sync_integration_to_habits(integration_id: int, payload: dict = Body({})): if not integration: raise HTTPException(status_code=404, detail='integration not found') - # Fetch events via existing events endpoint logic + # require owner or admin + from .rbac import require_owner_or_admin + require_owner_or_admin(integration.user_id)(None) + # Fetch events via existing events endpoint logic # Reuse token refresh + decrypt logic from oauth module token_row = db.query(models.OAuthToken).filter_by(integration_id=integration_id).order_by(models.OAuthToken.id.desc()).first() if not token_row: diff --git a/modern/backend/models.py b/modern/backend/models.py index b414cea..6464abc 100644 --- a/modern/backend/models.py +++ b/modern/backend/models.py @@ -15,6 +15,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, nullable=False, index=True) password_hash = Column(String) + role = Column(String, default='user') display_name = Column(String) created_at = Column(DateTime, server_default=func.current_timestamp()) updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) diff --git a/modern/backend/rbac.py b/modern/backend/rbac.py new file mode 100644 index 0000000..9aa98bf --- /dev/null +++ b/modern/backend/rbac.py @@ -0,0 +1,23 @@ +from fastapi import HTTPException +from .auth import get_current_user +from . import models + + +def require_role(min_role: str): + # Simple role hierarchy + hierarchy = {'user': 1, 'moderator': 2, 'admin': 3} + def _inner(request=None): + user = get_current_user(request) + if hierarchy.get(user.role, 0) < hierarchy.get(min_role, 0): + raise HTTPException(status_code=403, detail='insufficient role') + return user + return _inner + + +def require_owner_or_admin(resource_user_id: int): + def _inner(request=None): + user = get_current_user(request) + if user.id == resource_user_id or user.role == 'admin': + return user + raise HTTPException(status_code=403, detail='must be owner or admin') + return _inner diff --git a/modern/backend/requirements_full.txt b/modern/backend/requirements_full.txt index 4ae34d5..f1a86f4 100644 --- a/modern/backend/requirements_full.txt +++ b/modern/backend/requirements_full.txt @@ -6,3 +6,6 @@ python-dotenv requests cryptography boto3 +pytest +httpx +requests diff --git a/modern/frontend/index.html b/modern/frontend/index.html index 2041521..d91ff2b 100644 --- a/modern/frontend/index.html +++ b/modern/frontend/index.html @@ -1,12 +1,15 @@ - + + LifeRPG Modern - - + + +
- - + + + \ No newline at end of file diff --git a/modern/frontend/manifest.json b/modern/frontend/manifest.json index 74ad3f7..878d86a 100644 --- a/modern/frontend/manifest.json +++ b/modern/frontend/manifest.json @@ -1,9 +1,9 @@ { - "name": "LifeRPG Modern", - "short_name": "LifeRPG", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "description": "A modern, cross-platform habit-leveling app.", - "icons": [] -} + "name": "LifeRPG Modern", + "short_name": "LifeRPG", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "description": "A modern, cross-platform habit-leveling app.", + "icons": [] +} \ No newline at end of file diff --git a/modern/frontend/package.json b/modern/frontend/package.json index 55ac66a..e3e4e33 100644 --- a/modern/frontend/package.json +++ b/modern/frontend/package.json @@ -1,17 +1,17 @@ { - "name": "liferpg-modern-frontend", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "devDependencies": { - "vite": "^5.0.0" - } -} + "name": "liferpg-modern-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} \ No newline at end of file diff --git a/modern/frontend/src/App.jsx b/modern/frontend/src/App.jsx index 7edb30d..d509e91 100644 --- a/modern/frontend/src/App.jsx +++ b/modern/frontend/src/App.jsx @@ -3,14 +3,14 @@ import Integrations from './Integrations' import Guilds from './Guilds' import Login from './Login' -export default function App(){ - return ( -
-

LifeRPG Modern

-

Welcome — frontend scaffold. Connect to backend at /api/v1.

- - - -
- ) +export default function App() { + return ( +
+

LifeRPG Modern

+

Welcome — frontend scaffold. Connect to backend at /api/v1.

+ + + +
+ ) } diff --git a/modern/frontend/src/Guilds.jsx b/modern/frontend/src/Guilds.jsx index c23f45e..6f54f32 100644 --- a/modern/frontend/src/Guilds.jsx +++ b/modern/frontend/src/Guilds.jsx @@ -1,55 +1,55 @@ -import React, {useState, useEffect} from 'react' +import React, { useState, useEffect } from 'react' -const API = (path, opts) => fetch(path, {...(opts||{}), credentials: 'include'}).then(r=>r.json()) +const API = (path, opts) => fetch(path, { ...(opts || {}), credentials: 'include' }).then(r => r.json()) -export default function Guilds(){ - const [guilds, setGuilds] = useState([]) - const [name, setName] = useState('') - const [members, setMembers] = useState([]) - const [selectedGuild, setSelectedGuild] = useState(null) - const [userId] = useState(1) +export default function Guilds() { + const [guilds, setGuilds] = useState([]) + const [name, setName] = useState('') + const [members, setMembers] = useState([]) + const [selectedGuild, setSelectedGuild] = useState(null) + const [userId] = useState(1) - useEffect(()=>{ API('/api/v1/guilds').then(setGuilds).catch(()=>setGuilds([])) }, []) + useEffect(() => { API('/api/v1/guilds').then(setGuilds).catch(() => setGuilds([])) }, []) - function createGuild(){ - if(!name.trim()) return - API('/api/v1/guilds', {method:'POST', body: JSON.stringify({name, owner_id: userId}), headers: {'Content-Type':'application/json'}}) - .then(g=> setGuilds([...guilds, g])) - .catch(()=>{}) - } + function createGuild() { + if (!name.trim()) return + API('/api/v1/guilds', { method: 'POST', body: JSON.stringify({ name, owner_id: userId }), headers: { 'Content-Type': 'application/json' } }) + .then(g => setGuilds([...guilds, g])) + .catch(() => { }) + } - function loadMembers(gid){ - API(`/api/v1/guilds/${gid}/members`).then(setMembers).catch(()=>setMembers([])) - setSelectedGuild(gid) - } + function loadMembers(gid) { + API(`/api/v1/guilds/${gid}/members`).then(setMembers).catch(() => setMembers([])) + setSelectedGuild(gid) + } - function addMember(gid){ - const uid = prompt('User ID to add:') - if(!uid) return - API(`/api/v1/guilds/${gid}/members`, {method:'POST', body: JSON.stringify({user_id: parseInt(uid)}), headers: {'Content-Type':'application/json'}}) - .then(()=> loadMembers(gid)) - .catch(()=> alert('failed')) - } + function addMember(gid) { + const uid = prompt('User ID to add:') + if (!uid) return + API(`/api/v1/guilds/${gid}/members`, { method: 'POST', body: JSON.stringify({ user_id: parseInt(uid) }), headers: { 'Content-Type': 'application/json' } }) + .then(() => loadMembers(gid)) + .catch(() => alert('failed')) + } - return ( -
-

Guilds

-
- setName(e.target.value)} placeholder="Guild name" /> - -
- -

Members

- -
- ) + return ( +
+

Guilds

+
+ setName(e.target.value)} placeholder="Guild name" /> + +
+ +

Members

+ +
+ ) } diff --git a/modern/frontend/src/Integrations.jsx b/modern/frontend/src/Integrations.jsx index 6618e88..4b91da1 100644 --- a/modern/frontend/src/Integrations.jsx +++ b/modern/frontend/src/Integrations.jsx @@ -1,86 +1,86 @@ -import React, {useState, useEffect} from 'react' +import React, { useState, useEffect } from 'react' -const API = (path) => fetch(path, {credentials: 'include'}).then(r => r.json()) +const API = (path) => fetch(path, { credentials: 'include' }).then(r => r.json()) -export default function Integrations(){ - const [integrations, setIntegrations] = useState([]) - const [events, setEvents] = useState(null) - const [userId] = useState(1) - const [msg, setMsg] = useState(null) - const [loadingId, setLoadingId] = useState(null) +export default function Integrations() { + const [integrations, setIntegrations] = useState([]) + const [events, setEvents] = useState(null) + const [userId] = useState(1) + const [msg, setMsg] = useState(null) + const [loadingId, setLoadingId] = useState(null) - useEffect(()=>{ - API(`/api/v1/users/${userId}/integrations`).then(d=>setIntegrations(d)).catch(()=>setIntegrations([])) - }, [userId]) + useEffect(() => { + API(`/api/v1/users/${userId}/integrations`).then(d => setIntegrations(d)).catch(() => setIntegrations([])) + }, [userId]) - function startGoogle(){ - // Open backend OAuth URL in new window so the redirect can complete - window.open(`/api/v1/oauth/google/login?user_id=${userId}`, '_blank') - } + function startGoogle() { + // Open backend OAuth URL in new window so the redirect can complete + window.open(`/api/v1/oauth/google/login?user_id=${userId}`, '_blank') + } - function fetchEvents(integrationId){ - setLoadingId(integrationId) - fetch(`/api/v1/integrations/${integrationId}/google/events`, {credentials:'include'}) - .then(r=>r.json()) - .then(d=>{ - setEvents(d) - setMsg('Fetched events') - }) - .catch(e=>setEvents({error: String(e)})) - .finally(()=>setLoadingId(null)) - } + function fetchEvents(integrationId) { + setLoadingId(integrationId) + fetch(`/api/v1/integrations/${integrationId}/google/events`, { credentials: 'include' }) + .then(r => r.json()) + .then(d => { + setEvents(d) + setMsg('Fetched events') + }) + .catch(e => setEvents({ error: String(e) })) + .finally(() => setLoadingId(null)) + } - function previewEvents(integrationId){ - fetch(`/api/v1/integrations/${integrationId}/events_preview`, {credentials:'include'}) - .then(r=>r.json()).then(d=>{ - setEvents(d) - setMsg('Preview loaded') - }).catch(()=>setMsg('Preview failed')) - } + function previewEvents(integrationId) { + fetch(`/api/v1/integrations/${integrationId}/events_preview`, { credentials: 'include' }) + .then(r => r.json()).then(d => { + setEvents(d) + setMsg('Preview loaded') + }).catch(() => setMsg('Preview failed')) + } - function removeIntegration(integrationId){ - if(!confirm('Remove integration?')) return - setLoadingId(integrationId) - fetch(`/api/v1/integrations/${integrationId}`, {method: 'DELETE', credentials: 'include'}) - .then(r=>r.json()) - .then(d=>{ - setMsg('Integration removed') - setIntegrations(integrations.filter(i=>i.id !== integrationId)) - }) - .catch(e=>setMsg('Failed to remove')) - .finally(()=>setLoadingId(null)) - } + function removeIntegration(integrationId) { + if (!confirm('Remove integration?')) return + setLoadingId(integrationId) + fetch(`/api/v1/integrations/${integrationId}`, { method: 'DELETE', credentials: 'include' }) + .then(r => r.json()) + .then(d => { + setMsg('Integration removed') + setIntegrations(integrations.filter(i => i.id !== integrationId)) + }) + .catch(e => setMsg('Failed to remove')) + .finally(() => setLoadingId(null)) + } - function syncIntegration(integrationId){ - setLoadingId(integrationId) - fetch(`/api/v1/integrations/${integrationId}/sync_to_habits`, {method:'POST', credentials:'include'}) - .then(r=>r.json()) - .then(d=>setMsg(`Synced ${d.count || 0} items`)) - .catch(e=>setMsg('Sync failed')) - .finally(()=>setLoadingId(null)) - } + function syncIntegration(integrationId) { + setLoadingId(integrationId) + fetch(`/api/v1/integrations/${integrationId}/sync_to_habits`, { method: 'POST', credentials: 'include' }) + .then(r => r.json()) + .then(d => setMsg(`Synced ${d.count || 0} items`)) + .catch(e => setMsg('Sync failed')) + .finally(() => setLoadingId(null)) + } - return ( -
-

Integrations

- -

Your Integrations

- - {msg &&
{msg}
} -

Events

-
{events? JSON.stringify(events, null, 2): 'No events fetched'}
-
- ) + return ( +
+

Integrations

+ +

Your Integrations

+ + {msg &&
{msg}
} +

Events

+
{events ? JSON.stringify(events, null, 2) : 'No events fetched'}
+
+ ) } diff --git a/modern/frontend/src/Login.jsx b/modern/frontend/src/Login.jsx index 3047c56..801cbe6 100644 --- a/modern/frontend/src/Login.jsx +++ b/modern/frontend/src/Login.jsx @@ -1,25 +1,25 @@ -import React, {useState} from 'react' +import React, { useState } from 'react' -export default function Login(){ - const [email,setEmail]=useState('') - const [pw,setPw]=useState('') - const [msg,setMsg]=useState(null) +export default function Login() { + const [email, setEmail] = useState('') + const [pw, setPw] = useState('') + const [msg, setMsg] = useState(null) - function submit(e){ - e.preventDefault() - fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password: pw}), credentials:'include'}) - .then(r=>r.json()).then(()=> setMsg('Logged in')).catch(()=> setMsg('Login failed')) - } + function submit(e) { + e.preventDefault() + fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: pw }), credentials: 'include' }) + .then(r => r.json()).then(() => setMsg('Logged in')).catch(() => setMsg('Login failed')) + } - return ( -
-

Login

-
-
setEmail(e.target.value)} />
-
setPw(e.target.value)} />
- -
- {msg &&
{msg}
} -
- ) + return ( +
+

Login

+
+
setEmail(e.target.value)} />
+
setPw(e.target.value)} />
+ +
+ {msg &&
{msg}
} +
+ ) } diff --git a/modern/frontend/src/main.jsx b/modern/frontend/src/main.jsx index 60815b7..b9f3ae3 100644 --- a/modern/frontend/src/main.jsx +++ b/modern/frontend/src/main.jsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client' import App from './App' createRoot(document.getElementById('root')).render( - - - + + + ) diff --git a/modern/frontend/sw.js b/modern/frontend/sw.js index 4704e80..e03a13c 100644 --- a/modern/frontend/sw.js +++ b/modern/frontend/sw.js @@ -1,12 +1,12 @@ // Minimal service worker (cache-first for shell) const CACHE = 'liferpg-shell-v1' self.addEventListener('install', (e) => { - e.waitUntil( - caches.open(CACHE).then((cache) => cache.addAll(['/','/index.html'])) - ) + e.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(['/', '/index.html'])) + ) }) self.addEventListener('fetch', (e) => { - e.respondWith( - caches.match(e.request).then((r) => r || fetch(e.request)) - ) + e.respondWith( + caches.match(e.request).then((r) => r || fetch(e.request)) + ) }) diff --git a/modern/tests/test_auth.py b/modern/tests/test_auth.py new file mode 100644 index 0000000..a0e91a4 --- /dev/null +++ b/modern/tests/test_auth.py @@ -0,0 +1,13 @@ +import pytest +from fastapi.testclient import TestClient +from modern.backend.app import app + +client = TestClient(app) + +def test_signup_and_login(): + resp = client.post('/api/v1/auth/signup', json={'email':'test@example.com','password':'secret'}) + assert resp.status_code == 200 + resp = client.post('/api/v1/auth/login', json={'email':'test@example.com','password':'secret'}) + assert resp.status_code == 200 + assert 'session' in resp.cookies +*** End Patch diff --git a/modern/tests/test_integrations.py b/modern/tests/test_integrations.py new file mode 100644 index 0000000..2df80db --- /dev/null +++ b/modern/tests/test_integrations.py @@ -0,0 +1,10 @@ +import pytest +from fastapi.testclient import TestClient +from modern.backend.app import app + +client = TestClient(app) + +def test_list_integrations_empty(): + resp = client.get('/api/v1/integrations') + assert resp.status_code == 200 +*** End Patch