Backend (33/33 tests, +5 new): - Split Zoho parser's canonical "billable" into "billable" (bool column) and "billingType" (string column with values like "Client Related" / "Leave Hours" / "Idle Time"). Each parsed row now carries both, and billable is cross-filled from billingType when only the latter is present. - Merge service computes leaveHours separately from non_billable_h: any row with billingType "leave hours"/"leave" lands in the leave bucket and is no longer double-counted as non-billable. - UtilisationSummaryRow gains leaveHours: float; TimelogRow gains billingType: str | None. - /api/airtable/bookings accepts ?department=&name= (comma-separated multi-value), folded into the filterByFormula alongside the date overlap. Apostrophes in names are escaped. Cache key now includes the filter values so different selections don't collide. - /api/airtable/meta computes departments + employmentTypes from a live fetch_resources call (sorted distinct), falls back to the hardcoded lists on any exception. billingTypes/bookingStatuses stay static. - Logout cookie now mirrors the login cookie's HttpOnly / Secure / SameSite / Path attributes with max_age=0 and empty value, for consistency. Frontend (typecheck/lint/build clean): - types.ts: UtilisationSummaryRow.leaveHours: number. - BillabilityBreakdown uses r.leaveHours directly; idle becomes max(0, available - billable - nonBillable - leave). Capped to top 20 employees by (available + billable) with "Other (N)" rollup; Legend replaced with compact inline swatches. - BookingVsActual and FTEvsFreelancer: same top-20 + Other treatment to prevent the ProjectLoad-style x-axis explosion at scale. - Defensive sweep on WeeklyUtilisation, MonthlyUtilisation, BookingVsActual, FTEvsFreelancer: null-coerce sort keys, Number()- guard arithmetic, skip rows with no usable period/employee. - getBookings signature gains department + name; Resourcing passes them through. Client-side visibleBookings filter retained as belt-and-braces since linked-lookup filterByFormula on Airtable can be flaky. - Tutorial steps.ts restructured to cover the new chart and CSV export tags; existing TutorialOverlay defensive selector check preserved. - ErrorBoundary: removed dead eslint-disable directive flagged by --report-unused-disable-directives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
67 lines
2 KiB
Python
67 lines
2 KiB
Python
"""Session cookie signing helpers.
|
|
|
|
Decisions:
|
|
- Uses itsdangerous.TimestampSigner so we can enforce max-age server-side
|
|
even if the cookie's own Max-Age is tampered with (defence in depth).
|
|
- The cookie value is "<username>" signed; no JWT, no JSON. Sessions don't
|
|
need to carry data here — the user is single-tenant admin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
|
from fastapi import Response
|
|
|
|
from app.config import settings
|
|
|
|
|
|
def _signer() -> TimestampSigner:
|
|
return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1")
|
|
|
|
|
|
def sign_username(username: str) -> str:
|
|
return _signer().sign(username.encode("utf-8")).decode("utf-8")
|
|
|
|
|
|
def verify_signed(value: str, max_age: int | None = None) -> str | None:
|
|
"""Return username if signature valid and not expired; else None."""
|
|
try:
|
|
max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE
|
|
unsigned = _signer().unsign(value, max_age=max_age)
|
|
return unsigned.decode("utf-8")
|
|
except SignatureExpired:
|
|
return None
|
|
except BadSignature:
|
|
return None
|
|
|
|
|
|
COOKIE_NAME = "ud_session"
|
|
|
|
|
|
def set_session_cookie(response: Response, username: str) -> None:
|
|
signed = sign_username(username)
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=signed,
|
|
max_age=settings.SESSION_MAX_AGE,
|
|
path=settings.cookie_path,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def clear_session_cookie(response: Response) -> None:
|
|
# Match the login cookie's attributes exactly so browsers reliably
|
|
# overwrite/expire the original. Anything that differs (path,
|
|
# SameSite, Secure, HttpOnly) can cause the browser to treat this
|
|
# as a separate cookie and leave the original in place.
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value="",
|
|
max_age=0,
|
|
path=settings.cookie_path,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
)
|