feat(ProCanvas): retro game overhaul with log actions, challenges & achievements modals
- Complete ProCanvas redesign with retro sports game aesthetic - Card-game style photo frame with tilt, shimmer, corner star accents - Daily Missions card with weekly challenge + individual quest progress bars - Log Action card (Door Knocked, Lead Gained, Appointment Set, Client Meeting) - Each log action opens themed modal with relevant input fields - Challenges modal with 6 active challenges and progress tracking - Achievements modal with all badges, unlock status, descriptions - Nav bar tabs (Leaderboard, Challenges, Achievements) wired to modals - Rewards & Checkpoints with named stages (Daily Grind to Legend Run) - Smoother Hot Streak pulse animation (2.5s float + 3s pulse rings) - Hit The Map button with smooth pulsing glow animation - Grid pattern overlay in Leaderboard card - Light mode support via dark: Tailwind variants throughout - Top 3 badges displayed on profile card - Fixed dropdown option visibility in dark mode
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
# LLD — B1 Authentication Module
|
||||
|
||||
**Scope:** JWT auth, RBAC dependencies, cookie strategy, refresh token rotation, permissions, data masking
|
||||
**Companion doc:** `docs/backend/01_authentication_module.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Auth Module File Dependency Graph
|
||||
|
||||
How the four auth files relate to each other and to the rest of the backend.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph AuthFiles["B1 — Files Created"]
|
||||
SEC["security.py\nhash_password()\nverify_password()\ncreate_access_token()\ncreate_refresh_token()\ndecode_token()\nhash_token()\nverify_token_hash()"]
|
||||
AUTH_SVC["auth_service.py\nauthenticate_user()\ncreate_token_pair()"]
|
||||
DEPS["dependencies.py\nget_db()\nget_current_user()\nrequire_role(*roles)"]
|
||||
AUTH_RT["routers/auth.py\nPOST /auth/login\nPOST /auth/refresh\nPOST /auth/logout\nGET /auth/me"]
|
||||
end
|
||||
|
||||
subgraph Utils["Utilities"]
|
||||
PERM["permissions.py\nROLE_PERMISSIONS\nhas_permission()"]
|
||||
MASK["mask.py\nmask_ssn()\nmask_bank_account()\nmask_ein()"]
|
||||
end
|
||||
|
||||
subgraph External["External Dependencies"]
|
||||
PASSLIB["passlib[bcrypt]\npwd_context"]
|
||||
JOSE["python-jose\njwt.encode/decode"]
|
||||
CFG["config.py\nsettings.secret_key\nsettings.algorithm\nsettings.access_token_expire_minutes\nsettings.refresh_token_expire_days"]
|
||||
USER_MODEL["models/user.py\nUser ORM model"]
|
||||
end
|
||||
|
||||
subgraph AllRouters["All Other Routers (B3–B8)"]
|
||||
R3["properties.py"]
|
||||
R4["users.py"]
|
||||
R5["meetings.py"]
|
||||
R6["vendors.py"]
|
||||
R7["invoices.py"]
|
||||
R8["chatbot.py"]
|
||||
end
|
||||
|
||||
SEC --> PASSLIB
|
||||
SEC --> JOSE
|
||||
SEC --> CFG
|
||||
AUTH_SVC --> SEC
|
||||
AUTH_SVC --> USER_MODEL
|
||||
DEPS --> SEC
|
||||
DEPS --> USER_MODEL
|
||||
AUTH_RT --> AUTH_SVC
|
||||
AUTH_RT --> DEPS
|
||||
AUTH_RT --> PERM
|
||||
|
||||
DEPS -->|"require_role() imported by"| R3 & R4 & R5 & R6 & R7 & R8
|
||||
MASK -->|"called by"| R4
|
||||
|
||||
style SEC fill:#1e3a5f,color:#fff
|
||||
style AUTH_SVC fill:#1e3a5f,color:#fff
|
||||
style DEPS fill:#1e5f3a,color:#fff
|
||||
style AUTH_RT fill:#3a1e5f,color:#fff
|
||||
style PERM fill:#5f3a1e,color:#fff
|
||||
style MASK fill:#5f3a1e,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — Password Hashing Lifecycle
|
||||
|
||||
Two separate contexts where password functions are called: **user creation / seed** and **login verification**.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Creation["User Creation / Seed Script"]
|
||||
PLAIN1["plain: 'password'"]
|
||||
HASH_FN["hash_password(plain)\npwd_context.hash()"]
|
||||
DB_STORE["users.password_hash\n'$2b$12$...(60 chars)'"]
|
||||
|
||||
PLAIN1 --> HASH_FN --> DB_STORE
|
||||
end
|
||||
|
||||
subgraph Login["Login Verification"]
|
||||
PLAIN2["plain from request\nrequest.password"]
|
||||
DB_READ["users.password_hash\nfrom DB row"]
|
||||
VERIFY["verify_password(plain, hashed)\npwd_context.verify()"]
|
||||
TRUE["True → proceed"]
|
||||
FALSE["False → return None\n→ 401 Invalid credentials"]
|
||||
|
||||
PLAIN2 --> VERIFY
|
||||
DB_READ --> VERIFY
|
||||
VERIFY -->|match| TRUE
|
||||
VERIFY -->|no match| FALSE
|
||||
end
|
||||
|
||||
subgraph NeverDo["❌ Never Do This"]
|
||||
BAD1["store plain text\nin DB"]
|
||||
BAD2["compare strings directly\nplain == stored"]
|
||||
BAD3["use MD5 / SHA1\nfor passwords"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — JWT Token Anatomy
|
||||
|
||||
Side-by-side view of what's inside each token type and where it lives.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AccessToken["Access Token — 15 min"]
|
||||
direction TB
|
||||
AT_HDR["Header\n{ alg: HS256, typ: JWT }"]
|
||||
AT_PAY["Payload\n{\n sub: '3f8a1c2d-...(UUID)',\n role: 'FIELD_AGENT',\n legacy_id: 'e1',\n type: 'access',\n iat: 1740400000,\n exp: 1740400900\n}"]
|
||||
AT_SIG["Signature\nHMAC-SHA256(\n base64(header) + '.' + base64(payload),\n SECRET_KEY\n)"]
|
||||
AT_COOKIE["Cookie: access_token\nhttpOnly=true\nsecure=true\nsamesite=strict\npath=/\nmax_age=900s"]
|
||||
|
||||
AT_HDR --> AT_PAY --> AT_SIG --> AT_COOKIE
|
||||
end
|
||||
|
||||
subgraph RefreshToken["Refresh Token — 7 days"]
|
||||
direction TB
|
||||
RT_HDR["Header\n{ alg: HS256, typ: JWT }"]
|
||||
RT_PAY["Payload\n{\n sub: '3f8a1c2d-...(UUID)',\n type: 'refresh',\n iat: 1740400000,\n exp: 1741004800\n}"]
|
||||
RT_SIG["Signature\nHMAC-SHA256(...)"]
|
||||
RT_COOKIE["Cookie: refresh_token\nhttpOnly=true\nsecure=true\nsamesite=strict\npath=/api/v1/auth ← scoped\nmax_age=604800s"]
|
||||
RT_DB["DB: users.refresh_token_hash\nbcrypt hash of token string\n(not the token itself)"]
|
||||
|
||||
RT_HDR --> RT_PAY --> RT_SIG --> RT_COOKIE
|
||||
RT_SIG -.->|"hash stored in"| RT_DB
|
||||
end
|
||||
|
||||
note1["Access token has role + legacy_id\nso routers don't need extra DB lookup\nfor these common fields"]
|
||||
note2["Refresh token path scoped to /api/v1/auth\nso it is NOT sent with every API request —\nreduces exposure surface"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — Login Type Decision Tree
|
||||
|
||||
How `authenticate_user()` maps the frontend's `loginType` to a DB query strategy.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START["authenticate_user(\n db, identifier, password, login_type\n)"]
|
||||
|
||||
START --> LT{login_type?}
|
||||
|
||||
LT -->|"'customer'"| CUS["SELECT FROM users\nWHERE username = identifier\nAND role = 'CUSTOMER'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'employee'"| EMP["SELECT FROM users\nWHERE emp_id = identifier\nAND role IN ('FIELD_AGENT','ADMIN')\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'owner'"| OWN["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'OWNER'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'contractor'"| CON["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'CONTRACTOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'subcontractor'"| SUB["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'SUBCONTRACTOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'vendor'"| VEN["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'VENDOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"anything else"| NONE["return None\n→ 401"]
|
||||
|
||||
CUS & EMP & OWN & CON & SUB & VEN --> FOUND{user row\nreturned?}
|
||||
|
||||
FOUND -->|No| NONE2["return None → 401\n'Invalid credentials'"]
|
||||
FOUND -->|Yes| PWD["verify_password(\n request.password,\n user.password_hash\n)"]
|
||||
|
||||
PWD -->|False| NONE3["return None → 401\n'Invalid credentials'\n(same message — never\nreveal which field failed)"]
|
||||
PWD -->|True| RETURN["return User object\n→ create_token_pair()"]
|
||||
|
||||
subgraph SeedRef["Seed credentials reference"]
|
||||
S1["CUSTOMER: username='alice'"]
|
||||
S2["FIELD_AGENT: emp_id='FA001'–'FA005'"]
|
||||
S3["ADMIN: emp_id='ADM01'–'ADM03'"]
|
||||
S4["OWNER: username='justin'/'diana'"]
|
||||
S5["CONTRACTOR: username='mike'"]
|
||||
S6["SUBCONTRACTOR: username='carlos'"]
|
||||
S7["VENDOR: username='abc_supply'"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Refresh Token Rotation + Reuse Attack Detection
|
||||
|
||||
The full refresh flow, including the security branch when a reused refresh token is detected.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as React (interceptors.js)
|
||||
participant API as POST /auth/refresh
|
||||
participant SVC as auth_service.py
|
||||
participant SEC as security.py
|
||||
participant DB as users table
|
||||
|
||||
Note over FE: Received 401 on any request
|
||||
Note over FE: isRefreshing flag set to true
|
||||
Note over FE: Other 401s queued in failedQueue
|
||||
|
||||
FE->>API: POST /auth/refresh\nCookie: refresh_token=<jwt>
|
||||
|
||||
API->>SEC: decode_token(refresh_token)
|
||||
|
||||
alt Token missing or expired
|
||||
SEC-->>API: JWTError
|
||||
API-->>FE: 401 "Invalid or expired refresh token"
|
||||
FE->>FE: dispatch('auth:logout-required')
|
||||
FE->>FE: redirect to /login
|
||||
else Token valid
|
||||
SEC-->>API: { sub: user_id, type: "refresh" }
|
||||
API->>DB: SELECT * FROM users WHERE id = user_id
|
||||
|
||||
alt User not found / deactivated / soft-deleted
|
||||
DB-->>API: None
|
||||
API-->>FE: 401
|
||||
else User found
|
||||
DB-->>API: User row (includes refresh_token_hash)
|
||||
|
||||
API->>SEC: verify_token_hash(\n incoming_token,\n user.refresh_token_hash\n)
|
||||
|
||||
alt Hash mismatch ← REUSE ATTACK
|
||||
Note over API,DB: Token was already rotated.\nSomeone is replaying an old token.
|
||||
API->>DB: UPDATE users\nSET refresh_token_hash = NULL\n(revoke ALL sessions)
|
||||
API-->>FE: 401 "Refresh token reuse detected"
|
||||
FE->>FE: dispatch('auth:logout-required')
|
||||
FE->>FE: force logout + redirect /login
|
||||
else Hash matches — normal rotation
|
||||
API->>SEC: create_access_token(user_id, role, legacy_id)
|
||||
API->>SEC: create_refresh_token(user_id)
|
||||
SEC-->>API: new_access, new_refresh
|
||||
API->>SEC: hash_token(new_refresh)
|
||||
SEC-->>API: new_hash
|
||||
API->>DB: UPDATE users\nSET refresh_token_hash = new_hash\n last_login_at = NOW()
|
||||
API-->>FE: 200 Set-Cookie: access_token=new\nSet-Cookie: refresh_token=new
|
||||
Note over FE: processQueue(null) — release all queued requests
|
||||
FE->>FE: Retry original request(s)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Cookie Security Configuration
|
||||
|
||||
How the two cookies differ in path, scope, and lifetime, and why.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph AccessCookie["access_token Cookie"]
|
||||
AC1["key: 'access_token'"]
|
||||
AC2["httponly: True\n← JS cannot read it"]
|
||||
AC3["secure: True\n← HTTPS only"]
|
||||
AC4["samesite: 'strict'\n← blocks CSRF"]
|
||||
AC5["max_age: 900s (15 min)"]
|
||||
AC6["path: '/'\n← sent with ALL requests to backend"]
|
||||
AC1 --> AC2 --> AC3 --> AC4 --> AC5 --> AC6
|
||||
end
|
||||
|
||||
subgraph RefreshCookie["refresh_token Cookie"]
|
||||
RC1["key: 'refresh_token'"]
|
||||
RC2["httponly: True"]
|
||||
RC3["secure: True"]
|
||||
RC4["samesite: 'strict'"]
|
||||
RC5["max_age: 604800s (7 days)"]
|
||||
RC6["path: '/api/v1/auth'\n← only sent to /auth/* endpoints\nnot leaked with every API call"]
|
||||
RC1 --> RC2 --> RC3 --> RC4 --> RC5 --> RC6
|
||||
end
|
||||
|
||||
subgraph XSSProtection["Why httpOnly?"]
|
||||
XSS1["XSS attack injects script:\ndocument.cookie → blocked\nlocalStorage.getItem() → blocked\nfetch with credentials → blocked by samesite"]
|
||||
end
|
||||
|
||||
subgraph CSRFProtection["Why samesite=strict?"]
|
||||
CSRF1["Attacker tricks victim\ninto clicking evil.com link\nwhich sends request to api.lynkeduppro.com\nsamesite=strict: cookie NOT attached\nto cross-site navigation"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Permission Matrix Decision Flow
|
||||
|
||||
How `has_permission()` is used inside service functions (not routers) for fine-grained access control.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph RolePermTable["ROLE_PERMISSIONS — Full Matrix"]
|
||||
direction LR
|
||||
HDR["Permission → Role grants"]
|
||||
|
||||
VP["VIEW_ALL_PROJECTS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VPE["VIEW_ALL_PERSONNEL\n→ OWNER ✅ ADMIN ✅"]
|
||||
VF["VIEW_FINANCIALS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VS["VIEW_SENSITIVE_DATA\n→ OWNER ✅ only"]
|
||||
MU["MANAGE_USERS\n→ OWNER ✅ ADMIN ✅"]
|
||||
AD["APPROVE_DOCUMENTS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VAP["VIEW_ASSIGNED_PROJECTS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
UD["UPLOAD_DOCUMENTS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ VENDOR ✅"]
|
||||
SI["SUBMIT_INVOICES\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ VENDOR ✅"]
|
||||
MC["MANAGE_OWN_CREW\n→ CONTRACTOR ✅ only"]
|
||||
VAT["VIEW_ASSIGNED_TASKS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
UTS["UPDATE_TASK_STATUS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
end
|
||||
|
||||
subgraph Usage["Service Layer Usage Pattern"]
|
||||
SVC["service function receives\ncurrent_user: User"]
|
||||
CHECK["has_permission(\n current_user.role,\n 'VIEW_SENSITIVE_DATA'\n)"]
|
||||
GRANT["Return full data\nincluding SSN, EIN,\nbank accounts"]
|
||||
DENY["Return masked data\nmask_ssn(user.ssn)\nmask_ein(user.ein)\nmask_bank_account(user.bank)"]
|
||||
|
||||
SVC --> CHECK
|
||||
CHECK -->|True — OWNER only| GRANT
|
||||
CHECK -->|False — all other roles| DENY
|
||||
end
|
||||
|
||||
subgraph RouterVsService["Router vs Service Responsibility"]
|
||||
ROUTER_CHECK["Router:\nrequire_role('ADMIN','OWNER')\n← coarse: is this role allowed at all?"]
|
||||
SERVICE_CHECK["Service:\nhas_permission(role, 'VIEW_FINANCIALS')\n← fine: which fields/operations?"]
|
||||
ROUTER_CHECK -->|"if pass"| SERVICE_CHECK
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 8 — Sensitive Data Masking Flow
|
||||
|
||||
When `mask.py` is invoked per endpoint and per role.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
REQ["GET /users/{id}\nor GET /users (list)"]
|
||||
AUTH["get_current_user() → User\nrequire_role('ADMIN','OWNER','CONTRACTOR',..."]
|
||||
|
||||
AUTH --> ROLE{current_user.role?}
|
||||
|
||||
ROLE -->|OWNER| FULL["Return FULL data\nssn: '123-45-6789'\nein: '12-3456789'\nbank_account: '9876543210'"]
|
||||
|
||||
ROLE -->|ADMIN\nCONTRACTOR\nFIELD_AGENT\nVENDOR\nSUBCONTRACTOR| MASKED["Apply mask.py\nssn → mask_ssn() → '***-**-6789'\nein → mask_ein() → '**-***6789'\nbank → mask_bank_account() → '******3210'"]
|
||||
|
||||
ROLE -->|CUSTOMER| OWN_ONLY["CUSTOMER sees only\ntheir own record\nwith masked sensitive fields"]
|
||||
|
||||
subgraph MaskFunctions["mask.py — Functions"]
|
||||
M1["mask_ssn('123-45-6789')\n→ '***-**-6789'\nlast 4 digits visible"]
|
||||
M2["mask_bank_account('9876543210')\n→ '******3210'\nlast 4 digits visible"]
|
||||
M3["mask_ein('12-3456789')\n→ '**-***6789'\nlast 4 digits visible"]
|
||||
end
|
||||
|
||||
MASKED --> MaskFunctions
|
||||
OWN_ONLY --> MaskFunctions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 9 — `get_current_user()` Dependency Resolution
|
||||
|
||||
The exact path FastAPI takes to resolve the `get_current_user` dependency on every protected request.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
RT["Protected route handler called\nDepends(get_current_user)"]
|
||||
|
||||
RT --> COOKIE["Extract cookie:\naccess_token: str | None = Cookie(default=None)"]
|
||||
|
||||
COOKIE --> HAS{access_token\npresent?}
|
||||
HAS -->|No| E401A["raise HTTPException 401\n'Not authenticated'"]
|
||||
|
||||
HAS -->|Yes| DECODE["decode_token(access_token)\njwt.decode(token, SECRET_KEY, algorithms)"]
|
||||
|
||||
DECODE --> VALID{Valid\nsignature?}
|
||||
VALID -->|No / tampered| E401B["raise HTTPException 401\n'Not authenticated'"]
|
||||
|
||||
VALID -->|Yes| EXPIRED{Token\nexpired?}
|
||||
EXPIRED -->|Yes| E401C["raise HTTPException 401\n'Not authenticated'\n→ FE interceptor triggers /auth/refresh"]
|
||||
|
||||
EXPIRED -->|No| TYPE{"payload['type']\n== 'access'?"}
|
||||
TYPE -->|No (refresh token sent by mistake)| E401D["raise HTTPException 401"]
|
||||
|
||||
TYPE -->|Yes| SUB["user_id = payload['sub']\nrole = payload['role']"]
|
||||
|
||||
SUB --> DB["db.get(User, user_id)"]
|
||||
DB --> EXISTS{User found?\ndeleted_at IS NULL?\nis_active = True?}
|
||||
|
||||
EXISTS -->|No| E401E["raise HTTPException 401\n'Not authenticated'"]
|
||||
EXISTS -->|Yes| INJECT["return User object\n→ injected as current_user\ninto route handler"]
|
||||
|
||||
INJECT --> ROLE_CHECK["require_role() checker:\nif current_user.role not in allowed_roles\n→ 403 FORBIDDEN\nelse → proceed to handler"]
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user