security: add RBAC, HTTPS enforcement; add tests and CI pytest step
This commit is contained in:
parent
a2b8950d9a
commit
f0c61de280
8
modern/.github/workflows/ci.yml
vendored
8
modern/.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Check Python syntax
|
- name: Check Python syntax
|
||||||
run: python -m py_compile modern/backend/server.py
|
run: python -m py_compile modern/backend/*.py
|
||||||
- name: Check frontend package.json
|
- name: Run tests
|
||||||
run: cat modern/frontend/package.json
|
run: |
|
||||||
|
python -m pip install -r modern/backend/requirements_full.txt
|
||||||
|
pytest -q
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,18 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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')
|
@app.on_event('startup')
|
||||||
def startup_event():
|
def startup_event():
|
||||||
models.init_db()
|
models.init_db()
|
||||||
|
|
@ -198,13 +210,16 @@ def list_integrations():
|
||||||
|
|
||||||
|
|
||||||
@app.delete('/api/v1/integrations/{integration_id}')
|
@app.delete('/api/v1/integrations/{integration_id}')
|
||||||
def delete_integration(integration_id: int):
|
def delete_integration(integration_id: int, request=None):
|
||||||
db = models.SessionLocal()
|
db = models.SessionLocal()
|
||||||
try:
|
try:
|
||||||
row = db.query(models.Integration).filter_by(id=integration_id).first()
|
row = db.query(models.Integration).filter_by(id=integration_id).first()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail='integration not found')
|
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()
|
db.commit()
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -223,7 +238,10 @@ def sync_integration_to_habits(integration_id: int, payload: dict = Body({})):
|
||||||
if not integration:
|
if not integration:
|
||||||
raise HTTPException(status_code=404, detail='integration not found')
|
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
|
# 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()
|
token_row = db.query(models.OAuthToken).filter_by(integration_id=integration_id).order_by(models.OAuthToken.id.desc()).first()
|
||||||
if not token_row:
|
if not token_row:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class User(Base):
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
email = Column(String, unique=True, nullable=False, index=True)
|
email = Column(String, unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String)
|
password_hash = Column(String)
|
||||||
|
role = Column(String, default='user')
|
||||||
display_name = Column(String)
|
display_name = Column(String)
|
||||||
created_at = Column(DateTime, server_default=func.current_timestamp())
|
created_at = Column(DateTime, server_default=func.current_timestamp())
|
||||||
updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
|
|
|
||||||
23
modern/backend/rbac.py
Normal file
23
modern/backend/rbac.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,3 +6,6 @@ python-dotenv
|
||||||
requests
|
requests
|
||||||
cryptography
|
cryptography
|
||||||
boto3
|
boto3
|
||||||
|
pytest
|
||||||
|
httpx
|
||||||
|
requests
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>LifeRPG Modern</title>
|
<title>LifeRPG Modern</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "LifeRPG Modern",
|
"name": "LifeRPG Modern",
|
||||||
"short_name": "LifeRPG",
|
"short_name": "LifeRPG",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"description": "A modern, cross-platform habit-leveling app.",
|
"description": "A modern, cross-platform habit-leveling app.",
|
||||||
"icons": []
|
"icons": []
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "liferpg-modern-frontend",
|
"name": "liferpg-modern-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,14 +3,14 @@ import Integrations from './Integrations'
|
||||||
import Guilds from './Guilds'
|
import Guilds from './Guilds'
|
||||||
import Login from './Login'
|
import Login from './Login'
|
||||||
|
|
||||||
export default function App(){
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div style={{padding:20,fontFamily:'system-ui, sans-serif'}}>
|
<div style={{ padding: 20, fontFamily: 'system-ui, sans-serif' }}>
|
||||||
<h1>LifeRPG Modern</h1>
|
<h1>LifeRPG Modern</h1>
|
||||||
<p>Welcome — frontend scaffold. Connect to backend at <code>/api/v1</code>.</p>
|
<p>Welcome — frontend scaffold. Connect to backend at <code>/api/v1</code>.</p>
|
||||||
<Login />
|
<Login />
|
||||||
<Integrations />
|
<Integrations />
|
||||||
<Guilds />
|
<Guilds />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(){
|
export default function Guilds() {
|
||||||
const [guilds, setGuilds] = useState([])
|
const [guilds, setGuilds] = useState([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [members, setMembers] = useState([])
|
const [members, setMembers] = useState([])
|
||||||
const [selectedGuild, setSelectedGuild] = useState(null)
|
const [selectedGuild, setSelectedGuild] = useState(null)
|
||||||
const [userId] = useState(1)
|
const [userId] = useState(1)
|
||||||
|
|
||||||
useEffect(()=>{ API('/api/v1/guilds').then(setGuilds).catch(()=>setGuilds([])) }, [])
|
useEffect(() => { API('/api/v1/guilds').then(setGuilds).catch(() => setGuilds([])) }, [])
|
||||||
|
|
||||||
function createGuild(){
|
function createGuild() {
|
||||||
if(!name.trim()) return
|
if (!name.trim()) return
|
||||||
API('/api/v1/guilds', {method:'POST', body: JSON.stringify({name, owner_id: userId}), headers: {'Content-Type':'application/json'}})
|
API('/api/v1/guilds', { method: 'POST', body: JSON.stringify({ name, owner_id: userId }), headers: { 'Content-Type': 'application/json' } })
|
||||||
.then(g=> setGuilds([...guilds, g]))
|
.then(g => setGuilds([...guilds, g]))
|
||||||
.catch(()=>{})
|
.catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMembers(gid){
|
function loadMembers(gid) {
|
||||||
API(`/api/v1/guilds/${gid}/members`).then(setMembers).catch(()=>setMembers([]))
|
API(`/api/v1/guilds/${gid}/members`).then(setMembers).catch(() => setMembers([]))
|
||||||
setSelectedGuild(gid)
|
setSelectedGuild(gid)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMember(gid){
|
function addMember(gid) {
|
||||||
const uid = prompt('User ID to add:')
|
const uid = prompt('User ID to add:')
|
||||||
if(!uid) return
|
if (!uid) return
|
||||||
API(`/api/v1/guilds/${gid}/members`, {method:'POST', body: JSON.stringify({user_id: parseInt(uid)}), headers: {'Content-Type':'application/json'}})
|
API(`/api/v1/guilds/${gid}/members`, { method: 'POST', body: JSON.stringify({ user_id: parseInt(uid) }), headers: { 'Content-Type': 'application/json' } })
|
||||||
.then(()=> loadMembers(gid))
|
.then(() => loadMembers(gid))
|
||||||
.catch(()=> alert('failed'))
|
.catch(() => alert('failed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{marginTop:20}}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<h2>Guilds</h2>
|
<h2>Guilds</h2>
|
||||||
<div>
|
<div>
|
||||||
<input value={name} onChange={e=>setName(e.target.value)} placeholder="Guild name" />
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="Guild name" />
|
||||||
<button onClick={createGuild} style={{marginLeft:8}}>Create</button>
|
<button onClick={createGuild} style={{ marginLeft: 8 }}>Create</button>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{guilds && guilds.length ? guilds.map(g=> (
|
{guilds && guilds.length ? guilds.map(g => (
|
||||||
<li key={g.id}>
|
<li key={g.id}>
|
||||||
<strong>{g.name}</strong> (owner: {g.owner_id}) <button onClick={()=>loadMembers(g.id)}>Members</button>
|
<strong>{g.name}</strong> (owner: {g.owner_id}) <button onClick={() => loadMembers(g.id)}>Members</button>
|
||||||
<button style={{marginLeft:8}} onClick={()=>addMember(g.id)}>Add Member</button>
|
<button style={{ marginLeft: 8 }} onClick={() => addMember(g.id)}>Add Member</button>
|
||||||
</li>
|
</li>
|
||||||
)): <li>No guilds</li>}
|
)) : <li>No guilds</li>}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Members</h3>
|
<h3>Members</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{members && members.length ? members.map(m=> (<li key={m.id}>User {m.user_id} — {m.role}</li>)) : <li>No members</li>}
|
{members && members.length ? members.map(m => (<li key={m.id}>User {m.user_id} — {m.role}</li>)) : <li>No members</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(){
|
export default function Integrations() {
|
||||||
const [integrations, setIntegrations] = useState([])
|
const [integrations, setIntegrations] = useState([])
|
||||||
const [events, setEvents] = useState(null)
|
const [events, setEvents] = useState(null)
|
||||||
const [userId] = useState(1)
|
const [userId] = useState(1)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
const [loadingId, setLoadingId] = useState(null)
|
const [loadingId, setLoadingId] = useState(null)
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(() => {
|
||||||
API(`/api/v1/users/${userId}/integrations`).then(d=>setIntegrations(d)).catch(()=>setIntegrations([]))
|
API(`/api/v1/users/${userId}/integrations`).then(d => setIntegrations(d)).catch(() => setIntegrations([]))
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
function startGoogle(){
|
function startGoogle() {
|
||||||
// Open backend OAuth URL in new window so the redirect can complete
|
// Open backend OAuth URL in new window so the redirect can complete
|
||||||
window.open(`/api/v1/oauth/google/login?user_id=${userId}`, '_blank')
|
window.open(`/api/v1/oauth/google/login?user_id=${userId}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchEvents(integrationId){
|
function fetchEvents(integrationId) {
|
||||||
setLoadingId(integrationId)
|
setLoadingId(integrationId)
|
||||||
fetch(`/api/v1/integrations/${integrationId}/google/events`, {credentials:'include'})
|
fetch(`/api/v1/integrations/${integrationId}/google/events`, { credentials: 'include' })
|
||||||
.then(r=>r.json())
|
.then(r => r.json())
|
||||||
.then(d=>{
|
.then(d => {
|
||||||
setEvents(d)
|
setEvents(d)
|
||||||
setMsg('Fetched events')
|
setMsg('Fetched events')
|
||||||
})
|
})
|
||||||
.catch(e=>setEvents({error: String(e)}))
|
.catch(e => setEvents({ error: String(e) }))
|
||||||
.finally(()=>setLoadingId(null))
|
.finally(() => setLoadingId(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewEvents(integrationId){
|
function previewEvents(integrationId) {
|
||||||
fetch(`/api/v1/integrations/${integrationId}/events_preview`, {credentials:'include'})
|
fetch(`/api/v1/integrations/${integrationId}/events_preview`, { credentials: 'include' })
|
||||||
.then(r=>r.json()).then(d=>{
|
.then(r => r.json()).then(d => {
|
||||||
setEvents(d)
|
setEvents(d)
|
||||||
setMsg('Preview loaded')
|
setMsg('Preview loaded')
|
||||||
}).catch(()=>setMsg('Preview failed'))
|
}).catch(() => setMsg('Preview failed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeIntegration(integrationId){
|
function removeIntegration(integrationId) {
|
||||||
if(!confirm('Remove integration?')) return
|
if (!confirm('Remove integration?')) return
|
||||||
setLoadingId(integrationId)
|
setLoadingId(integrationId)
|
||||||
fetch(`/api/v1/integrations/${integrationId}`, {method: 'DELETE', credentials: 'include'})
|
fetch(`/api/v1/integrations/${integrationId}`, { method: 'DELETE', credentials: 'include' })
|
||||||
.then(r=>r.json())
|
.then(r => r.json())
|
||||||
.then(d=>{
|
.then(d => {
|
||||||
setMsg('Integration removed')
|
setMsg('Integration removed')
|
||||||
setIntegrations(integrations.filter(i=>i.id !== integrationId))
|
setIntegrations(integrations.filter(i => i.id !== integrationId))
|
||||||
})
|
})
|
||||||
.catch(e=>setMsg('Failed to remove'))
|
.catch(e => setMsg('Failed to remove'))
|
||||||
.finally(()=>setLoadingId(null))
|
.finally(() => setLoadingId(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncIntegration(integrationId){
|
function syncIntegration(integrationId) {
|
||||||
setLoadingId(integrationId)
|
setLoadingId(integrationId)
|
||||||
fetch(`/api/v1/integrations/${integrationId}/sync_to_habits`, {method:'POST', credentials:'include'})
|
fetch(`/api/v1/integrations/${integrationId}/sync_to_habits`, { method: 'POST', credentials: 'include' })
|
||||||
.then(r=>r.json())
|
.then(r => r.json())
|
||||||
.then(d=>setMsg(`Synced ${d.count || 0} items`))
|
.then(d => setMsg(`Synced ${d.count || 0} items`))
|
||||||
.catch(e=>setMsg('Sync failed'))
|
.catch(e => setMsg('Sync failed'))
|
||||||
.finally(()=>setLoadingId(null))
|
.finally(() => setLoadingId(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{marginTop:20}}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<h2>Integrations</h2>
|
<h2>Integrations</h2>
|
||||||
<button onClick={startGoogle}>Connect Google Calendar</button>
|
<button onClick={startGoogle}>Connect Google Calendar</button>
|
||||||
<h3>Your Integrations</h3>
|
<h3>Your Integrations</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{integrations && integrations.length ? integrations.map(i=> (
|
{integrations && integrations.length ? integrations.map(i => (
|
||||||
<li key={i.id} style={{marginBottom:8}}>
|
<li key={i.id} style={{ marginBottom: 8 }}>
|
||||||
<strong>{i.provider}</strong> — id: {i.id} — user: {i.user_id}
|
<strong>{i.provider}</strong> — id: {i.id} — user: {i.user_id}
|
||||||
<div style={{display:'inline-block', marginLeft:12}}>
|
<div style={{ display: 'inline-block', marginLeft: 12 }}>
|
||||||
<button onClick={()=>fetchEvents(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Fetch Events</button>
|
<button onClick={() => fetchEvents(i.id)} disabled={loadingId === i.id} style={{ marginRight: 6 }}>Fetch Events</button>
|
||||||
<button onClick={()=>previewEvents(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Preview</button>
|
<button onClick={() => previewEvents(i.id)} disabled={loadingId === i.id} style={{ marginRight: 6 }}>Preview</button>
|
||||||
<button onClick={()=>syncIntegration(i.id)} disabled={loadingId===i.id} style={{marginRight:6}}>Sync → Habits</button>
|
<button onClick={() => syncIntegration(i.id)} disabled={loadingId === i.id} style={{ marginRight: 6 }}>Sync → Habits</button>
|
||||||
<button onClick={()=>removeIntegration(i.id)} disabled={loadingId===i.id}>Remove</button>
|
<button onClick={() => removeIntegration(i.id)} disabled={loadingId === i.id}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)): <li>No integrations</li>}
|
)) : <li>No integrations</li>}
|
||||||
</ul>
|
</ul>
|
||||||
{msg && <div style={{marginTop:8, color:'#0366d6'}}>{msg}</div>}
|
{msg && <div style={{ marginTop: 8, color: '#0366d6' }}>{msg}</div>}
|
||||||
<h3>Events</h3>
|
<h3>Events</h3>
|
||||||
<pre style={{whiteSpace:'pre-wrap',background:'#f6f6f6',padding:10}}>{events? JSON.stringify(events, null, 2): 'No events fetched'}</pre>
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#f6f6f6', padding: 10 }}>{events ? JSON.stringify(events, null, 2) : 'No events fetched'}</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import React, {useState} from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
export default function Login(){
|
export default function Login() {
|
||||||
const [email,setEmail]=useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [pw,setPw]=useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [msg,setMsg]=useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
function submit(e){
|
function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password: pw}), credentials:'include'})
|
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'))
|
.then(r => r.json()).then(() => setMsg('Logged in')).catch(() => setMsg('Login failed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{marginTop:20}}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div><input placeholder="email" value={email} onChange={e=>setEmail(e.target.value)} /></div>
|
<div><input placeholder="email" value={email} onChange={e => setEmail(e.target.value)} /></div>
|
||||||
<div><input placeholder="password" type="password" value={pw} onChange={e=>setPw(e.target.value)} /></div>
|
<div><input placeholder="password" type="password" value={pw} onChange={e => setPw(e.target.value)} /></div>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
{msg && <div style={{marginTop:8}}>{msg}</div>}
|
{msg && <div style={{ marginTop: 8 }}>{msg}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Minimal service worker (cache-first for shell)
|
// Minimal service worker (cache-first for shell)
|
||||||
const CACHE = 'liferpg-shell-v1'
|
const CACHE = 'liferpg-shell-v1'
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.open(CACHE).then((cache) => cache.addAll(['/','/index.html']))
|
caches.open(CACHE).then((cache) => cache.addAll(['/', '/index.html']))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
self.addEventListener('fetch', (e) => {
|
self.addEventListener('fetch', (e) => {
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
caches.match(e.request).then((r) => r || fetch(e.request))
|
caches.match(e.request).then((r) => r || fetch(e.request))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
13
modern/tests/test_auth.py
Normal file
13
modern/tests/test_auth.py
Normal file
|
|
@ -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
|
||||||
10
modern/tests/test_integrations.py
Normal file
10
modern/tests/test_integrations.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user