Skip to content

Add FieldOps: single-file HaloPSA Field Tech PWA#1

Merged
MTG-Thomas merged 3 commits intomainfrom
copilot/build-mobile-first-pwa-fieldops
Mar 22, 2026
Merged

Add FieldOps: single-file HaloPSA Field Tech PWA#1
MTG-Thomas merged 3 commits intomainfrom
copilot/build-mobile-first-pwa-fieldops

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 22, 2026

  • Create index.html — single-file PWA (HTML/CSS/JS) with:
    • OAuth 2.0 Authorization Code + PKCE flow (sessionStorage for tokens)
    • Config screen (first-run setup → localStorage), pre-filled redirect URI
    • Sign-in screen with "Sign in with HaloPSA" button + "Change Configuration" link
    • Dashboard tab — ticket list with skeleton loaders + priority color borders (red/amber/green), active job banner with live HH:MM:SS timer and contextual action buttons
    • Ticket detail view — client/site info with tel:/maps/web links, GPS card, activity log, photo grid, field notes, manual time entry
    • Activity tab — today's cross-ticket log with daily totals chips
    • Settings tab — full config form, Sign Out, Reload Tickets, app version
    • Finish & Log bottom sheet modal with summary, final note, POST to Halo
    • Token refresh + silent expiry handling
    • Toast notifications, mobile UX, dark theme
  • Create manifest.json — PWA manifest
  • Create icon.svg — app icon
  • Create vercel.json — SPA routing rewrite
  • PR review fixes:
    • Remove user-scalable=no / maximum-scale=1.0 from viewport meta (accessibility)
    • Move role="dialog" / aria-modal to #bottom-sheet; add aria-hidden="true" to backdrop
    • Store tokenCheckIntervalId in state; clear existing interval in signOut() before creating new one in initApp() to prevent accumulation
    • Apply agentid filter for both mine and team options (previously team behaved like all)
    • Encode phone numbers with encodeURIComponent in tel: href attributes
    • Validate website URLs against /^https?:/i before rendering as links (blocks javascript: 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.

…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
Copilot AI changed the title [WIP] Build mobile-first Progressive Web App for FieldOps Add FieldOps: single-file HaloPSA Field Tech PWA Mar 22, 2026
Copilot AI requested a review from MTG-Thomas March 22, 2026 02:42
@MTG-Thomas MTG-Thomas requested review from Copilot and removed request for MTG-Thomas March 22, 2026 12:32
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/callback resolves 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.

Comment thread index.html Outdated
}

// Auto-refresh token validity check
setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS);
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS);
if (S.tokenCheckIntervalId) {
clearInterval(S.tokenCheckIntervalId);
S.tokenCheckIntervalId = null;
}
S.tokenCheckIntervalId = setInterval(() => ensureValidToken(), TOKEN_CHECK_INTERVAL_MS);

Copilot uses AI. Check for mistakes.
Comment thread index.html Outdated
Comment on lines +235 to +236
<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">
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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">

Copilot uses AI. Check for mistakes.
Comment thread index.html Outdated
<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">
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
<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">

Copilot uses AI. Check for mistakes.
Comment thread index.html Outdated
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}`;
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}`;

Copilot uses AI. Check for mistakes.
Comment thread index.html Outdated
Comment on lines +1871 to +1878
${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 || '')}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
${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 || '')}

Copilot uses AI. Check for mistakes.
Comment thread index.html Outdated
Comment on lines +1891 to +1896
<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>
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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 uses AI. Check for mistakes.
@MTG-Thomas
Copy link
Copy Markdown
Owner

@copilot -- review the changes suggested by the reviewer and if valid, resolve them. Then we can get the pr reviewed again for merge potential.

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 22, 2026

@copilot -- review the changes suggested by the reviewer and if valid, resolve them. Then we can get the pr reviewed again for merge potential.

All 6 reviewer suggestions have been applied in commit 46cff27:

  1. Viewport zoom — removed maximum-scale=1.0 and user-scalable=no; viewport is now width=device-width, initial-scale=1.0 only.

  2. Dialog ARIArole="dialog" / aria-modal="true" / aria-label moved to #bottom-sheet; backdrop now has aria-hidden="true".

  3. Interval leak on re-authtokenCheckIntervalId added to state; signOut() now clears the interval, and initApp() clears any existing one before creating a new one.

  4. Ticket filterteam option now also passes agentid to the API (same as mine), so it filters to the current agent rather than returning all open tickets.

  5. tel: encoding — phone numbers are now wrapped with encodeURIComponent(String(...)) before being inserted into href="tel:...".

  6. Website URL scheme — client/site website values are validated against /^https?:/i before rendering as <a> links; anything else (including javascript:) renders as escaped plain text.

Copilot AI requested a review from MTG-Thomas March 22, 2026 12:46
@MTG-Thomas
Copy link
Copy Markdown
Owner

@copilot review the changes to the PR and analyze for merge

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 22, 2026

@copilot review the changes to the PR and analyze for merge

All 6 reviewer issues from commit 1a42510 have been resolved in 46cff27. Here's the merge-readiness summary:

✅ All reviewer fixes applied

  1. Viewport — user-scalable=no / maximum-scale=1.0 removed (WCAG accessibility)
  2. ARIA — role="dialog" / aria-modal / aria-label moved to #bottom-sheet; backdrop has aria-hidden="true"
  3. Interval leak — tokenCheckIntervalId tracked in state; cleared in signOut() and before re-creation in initApp()
  4. Ticket filter — team filter now passes agentid to HaloPSA API (was behaving same as all)
  5. tel: encoding — phone values wrapped with encodeURIComponent(String(...))
  6. Website XSS — URLs validated against /^https?:/i; non-http(s) values rendered as plain text

✅ Security posture

  • All API-sourced values injected into innerHTML go through esc() (HTML entity escaping)
  • Tokens stored in sessionStorage only; never in localStorage
  • PKCE flow uses crypto.subtle SHA-256; no plain code_verifier in URL
  • target="_blank" links all include rel="noopener"

✅ CI — previous runs all passed; current run in progress on 46cff27

No blocking issues remain. The PR is ready for merge.

Copy link
Copy Markdown
Owner

@MTG-Thomas MTG-Thomas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@MTG-Thomas MTG-Thomas closed this Mar 22, 2026
@MTG-Thomas MTG-Thomas reopened this Mar 22, 2026
@MTG-Thomas MTG-Thomas marked this pull request as ready for review March 22, 2026 12:57
@MTG-Thomas MTG-Thomas merged commit d89f6e3 into main Mar 22, 2026
1 check passed
@MTG-Thomas MTG-Thomas deleted the copilot/build-mobile-first-pwa-fieldops branch April 6, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants