From 0b4705311bbd17be9914919d9d75d24ab6673bb3 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Sat, 30 May 2026 22:44:16 -0400 Subject: [PATCH] Session 2026-05-30: task detail page overhaul - New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2) - Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments - Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button - Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table - Folder tab: placeholder for file sharing - Tab badges showing revision/comment counts - Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons - Request Revision modal for clients on approved/invoiced/paid tasks - JSZip download-all with progress popup for submission files - Upload progress popup for Add Task and amend file uploads - FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override) - PageLoader on task detail load - task_comments migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 6 +- .gitignore | 0 README.md | 16 - api/filebrowser.js | 136 ++++- docs/file-sharing.md | 91 ++++ index.html | 7 + layout.md | 40 ++ src/App.jsx | 4 +- src/components/FileAttachment.jsx | 18 +- src/components/Layout.jsx | 38 +- src/components/RequestForm.jsx | 200 +++++--- src/data/mockData.js | 3 +- src/lib/requestSubmission.js | 19 +- src/pages/CompanyDetail.jsx | 78 +++ src/pages/DashboardPage.jsx | 457 ----------------- src/pages/FileSharing.jsx | 161 ++++-- src/pages/Projects.jsx | 590 ---------------------- src/pages/TaskDetail.jsx | 790 ++++++++++++++++++++++++++++++ src/pages/Tasks.jsx | 54 +- src/pages/team/TeamTasksPage.jsx | 327 ------------- supabase/.temp/cli-latest | 1 - supabase/.temp/gotrue-version | 1 - supabase/.temp/pooler-url | 1 - supabase/.temp/postgres-version | 1 - supabase/.temp/project-ref | 1 - supabase/.temp/rest-version | 1 - supabase/.temp/storage-migration | 1 - supabase/.temp/storage-version | 1 - 28 files changed, 1491 insertions(+), 1552 deletions(-) mode change 100755 => 100644 .gitignore delete mode 100755 README.md create mode 100644 docs/file-sharing.md delete mode 100644 src/pages/DashboardPage.jsx delete mode 100644 src/pages/Projects.jsx create mode 100644 src/pages/TaskDetail.jsx delete mode 100644 src/pages/team/TeamTasksPage.jsx delete mode 100644 supabase/.temp/cli-latest delete mode 100644 supabase/.temp/gotrue-version delete mode 100644 supabase/.temp/pooler-url delete mode 100644 supabase/.temp/postgres-version delete mode 100644 supabase/.temp/project-ref delete mode 100644 supabase/.temp/rest-version delete mode 100644 supabase/.temp/storage-migration delete mode 100644 supabase/.temp/storage-version diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 296d24a..ab9566d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -67,7 +67,11 @@ "Bash(git commit *)", "mcp__plugin_supabase_supabase__list_projects", "WebFetch(domain:github.com)", - "Skill(supabase:supabase)" + "Skill(supabase:supabase)", + "mcp__plugin_everything-claude-code_playwright__browser_navigate", + "mcp__plugin_everything-claude-code_exa__web_search_exa", + "WebFetch(domain:filebrowserquantum.com)", + "mcp__plugin_supabase_supabase__list_tables" ] } } diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/README.md b/README.md deleted file mode 100755 index a36934d..0000000 --- a/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# React + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/api/filebrowser.js b/api/filebrowser.js index f7ab8c2..0080da3 100644 --- a/api/filebrowser.js +++ b/api/filebrowser.js @@ -49,6 +49,8 @@ function getConfig() { return { url, token: process.env.FILEBROWSER_TOKEN || '', + adminUser: process.env.FILEBROWSER_ADMIN_USER || '', + adminPass: process.env.FILEBROWSER_ADMIN_PASS || '', teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), @@ -57,21 +59,121 @@ function getConfig() { }; } -function getToken(config) { - if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured'); - return config.token; +let runtimeToken = ''; +let runtimeTokenTs = 0; +const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000; + +function extractLoginToken(payload) { + if (typeof payload === 'string') { + const raw = payload.trim(); + if (raw.split('.').length === 3) return raw; + try { + const parsed = JSON.parse(raw); + return extractLoginToken(parsed); + } catch { + return ''; + } + } + if (!payload || typeof payload !== 'object') return ''; + return String( + payload.token + || payload.auth + || payload.access_token + || payload.jwt + || payload?.data?.token + || '' + ).trim(); +} + +async function loginForToken(config) { + if (!config.url || !config.adminUser || !config.adminPass) return ''; + const endpoints = ['/api/auth/login', '/api/login']; + let lastError = ''; + + for (const endpoint of endpoints) { + try { + const response = await fetch(`${config.url}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: config.adminUser, password: config.adminPass }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + // Some FileBrowser deployments only expose /api/auth/login. + // Ignore /api/login 404 and keep trying/using prior success signal. + if (endpoint === '/api/login' && response.status === 404) continue; + lastError = `${endpoint} -> ${response.status} ${text?.slice(0, 180) || ''}`.trim(); + continue; + } + const bodyText = await response.text(); + const payload = (() => { + try { + return JSON.parse(bodyText); + } catch { + return bodyText; + } + })(); + const token = extractLoginToken(payload); + if (token) return token; + lastError = `${endpoint} -> 200 but token missing`; + } catch { + lastError = `${endpoint} -> request failed`; + } + } + + if (lastError) { + const err = new Error(`FileBrowser login failed: ${lastError}`); + err.status = 401; + throw err; + } + return ''; +} + +async function getToken(config, forceRefresh = false) { + const now = Date.now(); + if (!forceRefresh && config.token && String(config.token).trim()) { + return String(config.token).trim(); + } + if (!forceRefresh && runtimeToken && (now - runtimeTokenTs) < RUNTIME_TOKEN_TTL_MS) { + return runtimeToken; + } + + if (!forceRefresh) { + const freshPreferred = await loginForToken(config); + if (freshPreferred) { + runtimeToken = freshPreferred; + runtimeTokenTs = now; + return runtimeToken; + } + } + + const fresh = await loginForToken(config); + if (!fresh) throw new Error('FileBrowser login returned no token.'); + runtimeToken = fresh; + runtimeTokenTs = now; + return runtimeToken; } async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { - const token = getToken(config); const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const url = `${config.url}${endpoint}?${qs}`; - const res = await fetch(url, { - method, - headers: { Authorization: `Bearer ${token}`, ...headers }, - body, - }); + async function callWith(token) { + return fetch(url, { + method, + headers: { Authorization: `Bearer ${token}`, ...headers }, + body, + }); + } + + let token = await getToken(config); + let res = await callWith(token); + + if (res.status === 401) { + // Always force-refresh and retry once on Unauthorized. + token = await getToken(config, true); + res = await callWith(token); + } if (!res.ok) { const text = await res.text(); @@ -329,7 +431,7 @@ export default async function handler(req, res) { if (req.method === 'GET' && action === 'download') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); - const token = getToken(config); + const token = await getToken(config); const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`; return json(res, 200, { url: downloadUrl, token }); } @@ -337,9 +439,14 @@ export default async function handler(req, res) { if (req.method === 'GET' && action === 'download-blob') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); - const token = getToken(config); + const token = await getToken(config); const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`; - const upstream = await fetch(downloadUrl); + let upstream = await fetch(downloadUrl); + if (upstream.status === 401) { + const fresh = await getToken(config, true); + const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`; + upstream = await fetch(retryUrl); + } if (!upstream.ok) { const text = await upstream.text(); return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` }); @@ -356,7 +463,7 @@ export default async function handler(req, res) { if (req.method === 'POST' && action === 'upload-token') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); - const token = getToken(config); + const token = await getToken(config); return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath }); } @@ -453,6 +560,7 @@ export default async function handler(req, res) { if (req.method === 'POST' && action === 'move') { const srcPath = req.body?.srcPath; const dstPath = req.body?.dstPath; + const mode = req.body?.mode === 'copy' ? 'copy' : 'move'; if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' }); const resolvedSrc = toFbPath(srcPath); @@ -466,7 +574,7 @@ export default async function handler(req, res) { params: {}, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - action: 'move', + action: mode, items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], overwrite: false, }), diff --git a/docs/file-sharing.md b/docs/file-sharing.md new file mode 100644 index 0000000..c2871f2 --- /dev/null +++ b/docs/file-sharing.md @@ -0,0 +1,91 @@ +# File Sharing — How It Works + +## Overview + +File sharing proxies all file operations through a Vercel serverless function (`/api/filebrowser`) to a self-hosted **FileBrowser Quantum** instance. The frontend never speaks directly to FBQ. Auth is validated server-side via Supabase on every request. + +--- + +## Infrastructure + +| Component | Location | +|---|---| +| FBQ instance | `https://fourgebranding.krao.us` (internal: `192.168.2.200:8082`) | +| API proxy | `api/filebrowser.js` (Vercel function) | +| Frontend | `src/pages/FileSharing.jsx` | + +--- + +## Auth Flow + +1. Frontend gets the current Supabase session access token (`supabase.auth.getSession()`). +2. Every request to `/api/filebrowser` sends the token as both `Authorization: Bearer ` header and `sb_access_token` query param. +3. The API function validates the token against Supabase and loads the caller's `profiles` row. +4. If valid, the function uses **its own server-side FBQ token** (`FILEBROWSER_TOKEN` env var) to talk to FBQ — the frontend never sees the FBQ token. +5. The FBQ token is a long-lived API token. If it expires or returns 401, the function falls back to admin username/password login (`FILEBROWSER_ADMIN_USER` / `FILEBROWSER_ADMIN_PASS`) to get a fresh token, cached in-memory for 30 minutes. + +**No JWT auth from the frontend to FBQ.** That feature was removed — portal users authenticate via Supabase only. + +--- + +## Role-Based Access Control + +The API function maps each portal role to a different FBQ root path: + +| Portal role | FBQ root | Notes | +|---|---|---| +| `team` | `FILEBROWSER_TEAM_ROOT` (default `/fourgebranding`) | Full access, sees everything under team root | +| `client` | `FILEBROWSER_CLIENT_ROOT/{company-name}` (default `/fourgebranding/Clients/{name}`) | Scoped to their company folder; multi-company clients get a virtual root listing their companies | +| `external` | `FILEBROWSER_SUBS_ROOT/{their-name}` + assigned project folders | Personal folder + read access to `Projects/{project}` folders they are members of via `project_members` table | + +Virtual directories (e.g. the multi-company root for clients, the `Projects/` node for externals) are synthesised by the API and never hit FBQ. + +Path traversal (`..`) is blocked at `normalizePath()` — throws before any FBQ call. + +--- + +## API Actions (`/api/filebrowser?action=...`) + +| Action | Method | What it does | +|---|---|---| +| `list` | GET | List folder contents; returns `{ entries, path, canGoUp, ... }` | +| `download` | GET | Returns a signed FBQ download URL + token for direct browser download | +| `download-blob` | GET | Proxies the file bytes through Vercel (used for ZIP building) | +| `upload-token` | POST | Returns FBQ token + URL for direct upload from browser to FBQ | +| `mkdir` | POST | Creates a new folder | +| `delete` | DELETE | Deletes one file/folder | +| `rename` | POST | Renames a file/folder in place | +| `move` | POST | Moves or copies items; `mode: 'copy'` or `mode: 'move'` | +| `archive-move` | POST | Merge-moves a folder into a destination (merges contents if dest already exists) | + +--- + +## Frontend (`FileSharing.jsx`) + +All FBQ calls go through the `fbqProxy()` helper which injects the current Supabase access token on every call. + +- **Single file download**: `download` action → signed URL → hidden `` click. +- **Multi-file / folder download**: `download-blob` streams each file, then JSZip builds a ZIP client-side. +- **Upload**: `upload-token` gets a short-lived FBQ token, then browser POSTs binary directly to `${fbqUrl}/api/resources?auth=${token}`. +- **Root path** per user is derived by `rootPath()` in `FileSharing.jsx` — team/external get `/`, clients with one company get `/Clients/{name}`, clients with multiple get `/Clients`. +- **Pinned folders** are persisted in `localStorage` keyed by `fbq_pins:{userId}`. +- **Nav tree** lazily loads folder children into a `navCache` ref; does not re-fetch already-loaded paths. +- **Drag-and-drop upload** uses `webkitGetAsEntry` to walk dropped folder trees, creates missing directories first, then uploads files. +- **Context menu** (right-click): Open, Download, Pin/Unpin, Copy, Cut, Paste here, Delete. Delete in context menu is restricted to `role === 'team'` (`accessScope.canManage`). + +--- + +## Required Env Vars + +``` +FILEBROWSER_URL=https://fourgebranding.krao.us +FILEBROWSER_TOKEN= +FILEBROWSER_ADMIN_USER=admin +FILEBROWSER_ADMIN_PASS= +FILEBROWSER_TEAM_ROOT=/fourgebranding +FILEBROWSER_CLIENT_ROOT=/fourgebranding/Clients +FILEBROWSER_SUBS_ROOT=/fourgebranding/team +FILEBROWSER_CLIENTS_ROOT=/fourgebranding/Clients +``` + +`FILEBROWSER_TOKEN` is the primary auth method. `ADMIN_USER`/`ADMIN_PASS` are only used for auto-refresh when the token expires. diff --git a/index.html b/index.html index 3171ca4..c066899 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,13 @@ + diff --git a/layout.md b/layout.md index 47ae385..9f0afbf 100644 --- a/layout.md +++ b/layout.md @@ -36,6 +36,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - Header title: `28px`, `500`, `line-height: 1.2` - Header subtitle: `13px` - Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px` +- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1` - Body table text: `12px/13px` by column importance ## 5) Card System @@ -98,6 +99,10 @@ This is the single source of truth for dashboard/profile visual structure and UI - Meta/date text: `11px` - Progress track: `height: 4px`, `radius: 2px` - Percentage width slot: `min-width: 28px` +- Empty states in all cards (dashboard, profile, tasks, projects, file sharing, etc.): + - any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`) + - use shared class treatment (`.card-empty-center`) for consistency + - avoid top-offset-only placement for empty text ## 11) Tables - General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse` @@ -107,6 +112,10 @@ This is the single source of truth for dashboard/profile visual structure and UI - Header cells: - `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px` - bottom spacing: `padding-bottom: 12px` + - sticky behavior for scrollable tables: + - table headers stay fixed while body scrolls + - use shared sticky-head treatment for all app tables (`position: sticky; top: 0`) + - table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active - Body cells: - primary text: `13px` - secondary/metrics text: `12px` @@ -229,6 +238,37 @@ This is the single source of truth for dashboard/profile visual structure and UI - Calendar hover popover: `1002` within card context (`card can be 1001 active`) - Modal overlay: `1200` +## 14.5) Loading Popup +- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations. +- Loading popups/overlays, including shared site-wide page loaders and file-sharing loading states, use the same widget shell as dashboard cards: + - `background: var(--popup-bg)` + - `border: 1px solid var(--border)` + - `border-radius: 8px` + - `padding: 18px 21px` + - `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter` +- Popup surface is theme-aware: + - dark mode: deep translucent dark surface + - light mode: bright translucent white surface +- Loading overlay scrim is theme-aware: + - dark mode: `rgba(0,0,0,0.58)` + - light mode: soft light scrim via `--overlay-scrim` +- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text +- Progress track: + - height: `4px` + - radius: `2px` + - track color: secondary card tone (`var(--card-bg-2)`) + - progress fill: accent +- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text + +## 14.6) Modal Form Fields +- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens: + - modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override + - field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)` + - input/select text: `13px`, color `var(--text-primary)`, left aligned + - textarea text: `13px`, color `var(--text-primary)` + - field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity + - modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role + ## 15) Motion - Motion vars: - fast `160ms` diff --git a/src/App.jsx b/src/App.jsx index 0611517..aabd340 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -44,6 +44,7 @@ const CompaniesPage = lazy(() => import('./pages/Companies')); const CompanyDetail = lazy(() => import('./pages/CompanyDetail')); const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices')); const RequestDetail = lazy(() => import('./pages/RequestDetail')); +const TaskDetail = lazy(() => import('./pages/TaskDetail')); const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice')); const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO')); const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail')); @@ -93,8 +94,9 @@ export default function App() { } /> } /> - } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx index 65910e6..c616eab 100644 --- a/src/components/FileAttachment.jsx +++ b/src/components/FileAttachment.jsx @@ -60,12 +60,12 @@ export default function FileAttachment({ files, onChange }) { return (
-