feat: remove login requirements for internal home-network use

- backend/app/security.py: require_session() now auto-authenticates by
  returning the first family_profile_id from the DB. No cookie or password
  needed. Falls back to "bootstrap" sentinel if no FamilyProfile exists.
  Admin routes (require_admin) still protected by bearer token.
- frontend/src/api/index.ts: removed 401→/login redirect interceptor
- frontend/src/App.tsx: removed Sign out button, removed /login route and
  Login page import
- Login page kept on disk (unused) for potential future re-enablement
This commit is contained in:
2026-05-14 11:27:09 -07:00
parent 6a0c9d0c4e
commit 26d9985433
4 changed files with 36 additions and 46 deletions
+30 -22
View File
@@ -4,18 +4,21 @@ Auth dependencies for the MealPlanner backend.
Two flavors: Two flavors:
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared - ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
to ``settings.ADMIN_TOKEN`` (must be set in env). to ``settings.ADMIN_TOKEN`` (must be set in env).
- ``require_session`` — signed-cookie session (``itsdangerous``) gating - ``require_session`` — auto-returns the first family_profile_id (no login
mutations on the family-facing routers; reads stay open inside the required). This app runs on a private home network so auth is disabled
trusted network. for family-facing routes. Kept as a dependency so admin/token endpoints
can be re-enabled later by restoring cookie logic.
The per-voter approval-token flow on meal items is intentionally NOT covered The per-voter approval-token flow on meal items is intentionally NOT covered
here — it has its own short-lived single-use tokens elsewhere. here — it has its own short-lived single-use tokens elsewhere.
""" """
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner from itsdangerous import TimestampSigner
from app.config import settings from app.config import settings
from app.database import get_db
from app.models import FamilyProfile
bearer_header = "Authorization" bearer_header = "Authorization"
@@ -49,22 +52,27 @@ def issue_session(family_profile_id: str) -> str:
def require_session(request: Request) -> str: def require_session(request: Request) -> str:
"""Return the family_profile_id stored in the signed session cookie.""" """Auto-authenticate: return the first family_profile_id from the DB.
No cookie or password needed — this app runs on a private home network.
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
initialise itself on first run.
"""
# 1. Try to read the signed cookie (backward-compat with existing sessions)
raw = request.cookies.get(SESSION_COOKIE) raw = request.cookies.get(SESSION_COOKIE)
if not raw: if raw:
raise HTTPException( try:
status_code=status.HTTP_401_UNAUTHORIZED, detail="Session required" return (
) _signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
try: )
family_id = ( except Exception:
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode() pass # fall through to auto-auth
)
except SignatureExpired: # 2. Auto-auth: grab the first family profile from the DB
raise HTTPException( db = next(get_db())
status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired" profile = db.query(FamilyProfile).first()
) if profile:
except BadSignature: return str(profile.id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session" # 3. Bootstrap hatch — no profile yet, return a sentinel value
) return "bootstrap"
return family_id
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-14
View File
@@ -5,8 +5,6 @@ import Dashboard from './pages/Dashboard'
import MealDetail from './pages/MealDetail' import MealDetail from './pages/MealDetail'
import Pantry from './pages/Pantry' import Pantry from './pages/Pantry'
import ShoppingList from './pages/ShoppingList' import ShoppingList from './pages/ShoppingList'
import Login from './pages/Login'
import { mealPlannerApi } from './api'
const queryClient = new QueryClient() const queryClient = new QueryClient()
@@ -30,17 +28,6 @@ function Navigation() {
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link> <Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link> <Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
</div> </div>
<div className="flex items-center">
<button
onClick={async () => {
await mealPlannerApi.auth.logout().catch(() => {})
window.location.href = '/login'
}}
className="text-sm font-medium text-surface-500 hover:text-surface-900 px-3 py-2 rounded-lg hover:bg-surface-100 transition-colors"
>
Sign out
</button>
</div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -60,7 +47,6 @@ function App() {
<Route path="/meals/:id" element={<MealDetail />} /> <Route path="/meals/:id" element={<MealDetail />} />
<Route path="/pantry" element={<Pantry />} /> <Route path="/pantry" element={<Pantry />} />
<Route path="/shopping-list" element={<ShoppingList />} /> <Route path="/shopping-list" element={<ShoppingList />} />
<Route path="/login" element={<Login />} />
</Routes> </Routes>
</main> </main>
</div> </div>
-10
View File
@@ -10,16 +10,6 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401 && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const mealPlannerApi = { export const mealPlannerApi = {
auth: { auth: {
login: (password: string) => api.post('/auth/login', { password }), login: (password: string) => api.post('/auth/login', { password }),