Add FieldOps: single-file HaloPSA Field Tech PWA#1
Conversation
…svg, vercel.json) Co-authored-by: MTG-Thomas <87775189+MTG-Thomas@users.noreply.github.com> Agent-Logs-Url: https://github.com/MTG-Thomas/fieldops-/sessions/ccf4fba7-0cfe-4999-98ea-d193cfa9c27d
There was a problem hiding this comment.
Pull request overview
Adds FieldOps, a static-deployable, single-file PWA that connects directly to HaloPSA from the browser to help field technicians manage tickets, track travel/on-site time, and post time entries without any backend.
Changes:
- Introduces the full FieldOps app UI + logic (PKCE auth, ticket loading, job tracking, photos, and time entry posting) in a single
index.html. - Adds PWA/deployment assets (
manifest.json,icon.svg) and a Vercel SPA rewrite (vercel.json) so/auth/callbackresolves correctly.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
index.html |
Implements the entire single-file PWA (UI, state, PKCE auth, HaloPSA API integration, job workflow). |
manifest.json |
Declares PWA metadata (name/theme/start URL/icon). |
icon.svg |
Provides the app icon referenced by the manifest. |
vercel.json |
Adds a rewrite rule to support SPA-style routing (e.g., /auth/callback). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| // Auto-refresh token validity check | ||
| setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS); |
There was a problem hiding this comment.
setInterval(() => ensureValidToken(), ...) is created each time initApp() runs, but the interval handle isn’t stored/cleared on sign-out. Signing out and back in will accumulate intervals (extra refresh attempts, harder debugging). Store the interval ID in state and clear it in signOut() (and before starting a new interval).
| setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS); | |
| if (S.tokenCheckIntervalId) { | |
| clearInterval(S.tokenCheckIntervalId); | |
| S.tokenCheckIntervalId = null; | |
| } | |
| S.tokenCheckIntervalId = setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS); |
| <div id="sheet-backdrop" class="sheet-backdrop hidden" role="dialog" aria-modal="true" aria-label="Finish and log job"></div> | ||
| <div id="bottom-sheet" class="bottom-sheet hidden"> |
There was a problem hiding this comment.
The backdrop element is marked as role="dialog"/aria-modal="true", but the actual dialog content is in #bottom-sheet. This will confuse screen readers (the dialog has no focusable content). Move the dialog role/label to the sheet container and ensure focus is managed appropriately when opening/closing.
| <div id="sheet-backdrop" class="sheet-backdrop hidden" role="dialog" aria-modal="true" aria-label="Finish and log job"></div> | |
| <div id="bottom-sheet" class="bottom-sheet hidden"> | |
| <div id="sheet-backdrop" class="sheet-backdrop hidden" aria-hidden="true"></div> | |
| <div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Finish and log job" tabindex="-1"> |
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
There was a problem hiding this comment.
The viewport meta disables pinch-zoom (maximum-scale=1.0, user-scalable=no), which is an accessibility violation for users who rely on zoom. Consider removing those directives and keeping only width=device-width, initial-scale=1.0 (or allowing user scaling).
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| try { | ||
| const filter = S.config?.ticketFilter || 'mine'; | ||
| let url = '/Tickets?open_only=true&count=50'; | ||
| if (filter === 'mine' && S.auth?.agent_id) url += `&agentid=${S.auth.agent_id}`; |
There was a problem hiding this comment.
Default Ticket Filter offers team and all, but loadTickets() only treats mine specially; team currently behaves the same as all (no additional query params). Either implement the team/all semantics against HaloPSA’s API or remove those options to avoid misleading configuration.
| if (filter === 'mine' && S.auth?.agent_id) url += `&agentid=${S.auth.agent_id}`; | |
| if ((filter === 'mine' || filter === 'team') && S.auth?.agent_id) url += `&agentid=${S.auth.agent_id}`; |
| ${client.phone ? `<a href="tel:${client.phone}" class="info-link">${esc(client.name)}</a>` : esc(client.name || '')} | ||
| </span> | ||
| </div>` : ''} | ||
| ${site ? ` | ||
| <div class="info-row"> | ||
| <span class="info-label">Site</span> | ||
| <span class="info-value"> | ||
| ${site.phone ? `<a href="tel:${site.phone}" class="info-link">${esc(site.name)}</a>` : esc(site.name || '')} |
There was a problem hiding this comment.
The tel: links interpolate client.phone / site.phone directly into an HTML attribute. Since these values come from the API, they should be escaped/normalized before being inserted into href to avoid attribute injection or broken markup.
| ${client.phone ? `<a href="tel:${client.phone}" class="info-link">${esc(client.name)}</a>` : esc(client.name || '')} | |
| </span> | |
| </div>` : ''} | |
| ${site ? ` | |
| <div class="info-row"> | |
| <span class="info-label">Site</span> | |
| <span class="info-value"> | |
| ${site.phone ? `<a href="tel:${site.phone}" class="info-link">${esc(site.name)}</a>` : esc(site.name || '')} | |
| ${client.phone ? `<a href="tel:${encodeURIComponent(String(client.phone))}" class="info-link">${esc(client.name)}</a>` : esc(client.name || '')} | |
| </span> | |
| </div>` : ''} | |
| ${site ? ` | |
| <div class="info-row"> | |
| <span class="info-label">Site</span> | |
| <span class="info-value"> | |
| ${site.phone ? `<a href="tel:${encodeURIComponent(String(site.phone))}" class="info-link">${esc(site.name)}</a>` : esc(site.name || '')} |
| <span class="info-value"><a href="${esc(client.website)}" target="_blank" rel="noopener" class="info-link">${esc(client.website)}</a></span> | ||
| </div>` : ''} | ||
| ${site?.website && site.website !== client?.website ? ` | ||
| <div class="info-row"> | ||
| <span class="info-label">Site web</span> | ||
| <span class="info-value"><a href="${esc(site.website)}" target="_blank" rel="noopener" class="info-link">${esc(site.website)}</a></span> |
There was a problem hiding this comment.
client.website / site.website are inserted as clickable links without validating allowed URL schemes. esc() prevents HTML injection but does not prevent javascript: (or other dangerous schemes), which can lead to XSS when clicked. Consider allowing only http:/https: (and maybe mailto:) and rendering anything else as plain text.
| <span class="info-value"><a href="${esc(client.website)}" target="_blank" rel="noopener" class="info-link">${esc(client.website)}</a></span> | |
| </div>` : ''} | |
| ${site?.website && site.website !== client?.website ? ` | |
| <div class="info-row"> | |
| <span class="info-label">Site web</span> | |
| <span class="info-value"><a href="${esc(site.website)}" target="_blank" rel="noopener" class="info-link">${esc(site.website)}</a></span> | |
| <span class="info-value">${ | |
| /^(https?:|mailto:)/i.test(client.website) | |
| ? `<a href="${esc(client.website)}" target="_blank" rel="noopener" class="info-link">${esc(client.website)}</a>` | |
| : esc(client.website) | |
| }</span> | |
| </div>` : ''} | |
| ${site?.website && site.website !== client?.website ? ` | |
| <div class="info-row"> | |
| <span class="info-label">Site web</span> | |
| <span class="info-value">${ | |
| /^(https?:|mailto:)/i.test(site.website) | |
| ? `<a href="${esc(site.website)}" target="_blank" rel="noopener" class="info-link">${esc(site.website)}</a>` | |
| : esc(site.website) | |
| }</span> |
|
@copilot -- review the changes suggested by the reviewer and if valid, resolve them. Then we can get the pr reviewed again for merge potential. |
…vior Co-authored-by: MTG-Thomas <87775189+MTG-Thomas@users.noreply.github.com> Agent-Logs-Url: https://github.com/MTG-Thomas/fieldops/sessions/974b7256-3992-4aa5-ab93-b460842d7536
All 6 reviewer suggestions have been applied in commit
|
|
@copilot review the changes to the PR and analyze for merge |
All 6 reviewer issues from commit ✅ All reviewer fixes applied
✅ Security posture
✅ CI — previous runs all passed; current run in progress on No blocking issues remain. The PR is ready for merge. |
MTG-Thomas
left a comment
There was a problem hiding this comment.
Good work! Create an issue in the repo so I can keep track of this priority and get a place to deploy this app sorted out. I approve this merge
index.html— single-file PWA (HTML/CSS/JS) with:manifest.json— PWA manifesticon.svg— app iconvercel.json— SPA routing rewriteuser-scalable=no/maximum-scale=1.0from viewport meta (accessibility)role="dialog"/aria-modalto#bottom-sheet; addaria-hidden="true"to backdroptokenCheckIntervalIdin state; clear existing interval insignOut()before creating new one ininitApp()to prevent accumulationagentidfilter for bothmineandteamoptions (previouslyteambehaved likeall)encodeURIComponentintel:href attributes/^https?:/ibefore rendering as links (blocksjavascript:and other dangerous schemes)Original prompt
Here you go — sleep well!
FieldOps — HaloPSA Field Tech PWA: Coding Agent Prompt
Overview
Build a mobile-first, single-file HTML/CSS/JS Progressive Web App called FieldOps for field technicians at a Managed Services Provider. The app connects to a HaloPSA instance, loads the authenticated tech’s assigned tickets, and provides streamlined field workflows: GPS check-in, travel/onsite time tracking, photo capture, field notes, and time entry posting back to Halo.
Deployment target: Static host (Cloudflare Pages, Vercel, Netlify). No backend. All API calls go browser → HaloPSA directly.
Reference repo: https://github.com/MTG-Thomas/HaloSqlStudio — this is a working HaloPSA OAuth SPA using Authorization Code + PKCE. Mirror its auth pattern exactly, including the /auth/callback route handling and token storage approach.
Authentication
Implement OAuth 2.0 Authorization Code flow with PKCE. Do not use Client Credentials.
∙ On first load (no valid token), show a “Sign in with Halo” screen
∙ Generate a cryptographically random code_verifier (43–128 chars, URL-safe), derive code_challenge via SHA-256, base64url encode
∙ Redirect to {authServer}/authorize with params: response_type=code, client_id, redirect_uri, scope=all, code_challenge, code_challenge_method=S256
∙ On callback (detect ?code= in URL), exchange code for token via POST {authServer}/token
∙ Store access_token, refresh_token, and expires_at in sessionStorage only — never localStorage
∙ Silently refresh the access token before expiry using the refresh token
∙ Include a visible Sign Out button that clears sessionStorage and reloads
∙ Required Halo API app config: Grant type Authorization Code, redirect URI must match the deployed domain exactly, scopes: read:tickets edit:tickets read:customers read:site edit:actions
Configuration
On first run (no config stored), show a settings screen to collect:
∙ Tenant (subdomain, e.g. midtown)
∙ Auth Server URL (e.g. https://midtown.halopsa.com/auth)
∙ Resource Server URL (e.g. https://midtown.halopsa.com/api)
∙ Client ID
∙ Redirect URI (pre-filled with window.location.origin + '/auth/callback', editable)
∙ Default ticket filter (Assigned to me / My team / All open)
Store config in localStorage (non-sensitive). After saving, trigger the PKCE auth flow.
API Endpoints
All requests: Authorization: Bearer {token} header, base URL = Resource Server URL.
Tickets
∙ GET /Tickets?open_only=true&count=50&agentid={agentId} — load assigned tickets
∙ Response fields to use: id, summary, client_name, client_id, site_id, priority_name, status_name
Client detail (fetch on ticket open, cache per session)
∙ GET /Client/{client_id}
∙ Fields: name, website, phone, toplevel_id
Site detail (fetch on ticket open, cache per session)
∙ GET /Site/{site_id}
∙ Fields: name, phone, address_1, address_2, city, state, postcode, website
Post time entry
∙ POST /Action with body array:
[{
"ticket_id": 123,
"timetaken": 90,
"startdatetime": "2025-03-21T09:00:00.000Z",
"note": "On-site work performed...",
"billable": true,
"actiontype_id": 2
}]
timetaken is integer minutes. actiontype_id: 2 is the standard time entry type — make this configurable in settings in case the tenant uses a different ID.
UI Structure
Three-tab bottom navigation: Dashboard, Activity, Settings.
A sticky header shows the app name and a connection badge (tenant name + colored dot: green = authenticated, gray = not connected, red = error).
Dashboard Tab
Active Job Banner (shown when a job is in progress, sticky below header):
∙ Pulsing indicator, ticket number + summary truncated to one line
∙ Live elapsed timer (HH:MM:SS) counting from check-in start
∙ Contextual action buttons: Start Travel → Stop Travel → Check In → Check Out → Finish & Log
∙ Only show the relevant next action, not all buttons at once
Ticket List (below banner):
∙ Load on auth, show skeleton loaders while fetching
∙ Each ticket card shows: ticket ID (monospace), summary, client name, priority indicator (left border color: red/high, amber/medium, green/low)
∙ Tap card → Ticket Detail screen (not a separate tab, a pushed view)
Ticket Detail Screen
Back button returns to Dashboard. Header shows ticket ID, summary, client name.
Client & Site Info Card:
∙ Client name as tappable tel: link if phone available
∙ Site name as tappable tel: link if phone available
∙ Site address composed and linked to https://maps.google.com/?q=URLENCODED_ADDRESS — this URL works cross-platform; iOS Safari will offer Apple Maps or Google Maps, Android will use the default maps app
∙ Client website as tappable https:// link (opens in new tab)
∙ Site website as tappable https:// link if different from client website
∙ “Wrong site?” — a small secondary link: View all sites for {client_name} that opens {resourceServer}/client/{client_id} — this lets the tech navigate to Halo directly if the ticket is misclassified
GPS Card:
∙ Shows current fix status and coordinates (lat/lon ± accuracy i...
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.