From e9d4326dd2066259c00d6f3ae86705946c3dee10 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Fri, 22 May 2026 06:47:12 +0000 Subject: [PATCH 1/2] Add Azure DevOps Business Central time tracker --- PRIVACY.md | 9 ++++ README.md | 87 ++++++++++++++++------------------ TERMS.md | 10 ++++ auth.js | 69 +++++++++++++++++++++++++++ bc-api.js | 81 +++++++++++++++++++++++++++++++ img/icon.png | Bin 0 -> 331 bytes img/logo.png | Bin 0 -> 331 bytes img/preview.png | Bin 0 -> 626 bytes package.json | 10 ++++ test/validate.js | 12 +++++ time-tracker.css | 17 +++++++ time-tracker.html | 48 +++++++++++++++++++ time-tracker.js | 115 +++++++++++++++++++++++++++++++++++++++++++++ vss-extension.json | 43 +++++++++++++++++ 14 files changed, 455 insertions(+), 46 deletions(-) create mode 100644 PRIVACY.md create mode 100644 TERMS.md create mode 100644 auth.js create mode 100644 bc-api.js create mode 100644 img/icon.png create mode 100644 img/logo.png create mode 100644 img/preview.png create mode 100644 package.json create mode 100644 test/validate.js create mode 100644 time-tracker.css create mode 100644 time-tracker.html create mode 100644 time-tracker.js create mode 100644 vss-extension.json diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..75c86be --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,9 @@ +# Privacy Policy + +This extension stores minimal data required for operation: +- OAuth access token (MVP/local storage) +- local references between work item IDs and time entries + +Data sent to Business Central includes time entry fields (date, quantity, optional job number, description context). + +No data is sold. For production, use secure server-side token handling and enterprise retention policies. diff --git a/README.md b/README.md index c1a0c02..bd76153 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,44 @@ -# Business Central Time Tracker for Azure DevOps +# Business Central Time Tracker (Azure DevOps Extension) -Track time on Azure DevOps work items and sync seamlessly to Microsoft Dynamics 365 Business Central. - -## Overview - -This Azure DevOps extension adds time tracking capabilities directly within work items, allowing teams to log time and automatically sync entries to Business Central's timesheet system. +Tracks time inside Azure DevOps work items and syncs entries to Microsoft Dynamics 365 Business Central. ## Features - -- **Manual Time Entry** - Add time entries with date, duration, and description directly from work items -- **Time History** - View all time entries logged for each work item -- **Business Central Integration** - Automatic synchronization with BC timeRegistrationEntry API -- **OAuth Authentication** - Secure authentication via Microsoft Entra ID - -## Architecture - -**Extension Type**: Work Item Form Extension -**Target Platform**: Azure DevOps Services & Server -**Integration**: Business Central API v2.0 -**Authentication**: OAuth 2.0 (Microsoft Entra ID) - -## Development Status - -This project is currently in planning phase. See [Issue #1](https://github.com/knowall-ai/devops-bc-timetracker/issues/1) for complete implementation details and technical specifications. - -## Reference Implementations - -- [Harvest Time Tracking for Azure DevOps](https://marketplace.visualstudio.com/items?itemName=SaaSKit.HarvestTimeTrackingForAzureDevOps) -- [Zendesk BC TimeTracker](https://github.com/knowall-ai/zendesk-bc-timetracker) - -## Documentation - -- [Azure DevOps Extension Development](https://learn.microsoft.com/en-us/azure/devops/extend/) -- [Business Central API v2.0](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/) -- [timeRegistrationEntry Resource](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_timeregistrationentry) - -## Contributing - -Contributions are welcome! Please check [Issue #1](https://github.com/knowall-ai/devops-bc-timetracker/issues/1) for the complete development roadmap and technical specifications. - -## License - -MIT License - see [LICENSE](LICENSE) file for details. - -## Contact - -**Organization**: KnowAll.ai -**Email**: ben.weeks@outlook.com +- Manual time entry (date, hours, description, optional BC job number) +- Work-item scoped history with total time +- Date-range filtering +- Sync status labels (`synced`, `pending`, `error`) +- OAuth connect button for Business Central access token flow + +## Files +- `vss-extension.json` extension manifest +- `time-tracker.html` UI shell +- `time-tracker.css` styles +- `time-tracker.js` work-item integration + state +- `auth.js` auth handling (demo-safe no secret-in-code flow) +- `bc-api.js` Business Central API adapter + local fallback + +## Dev/Test (standalone) +Open `time-tracker.html?workItemId=12345` via local static file host to test UI logic. + +## Packaging +Install tfx CLI and package: + +```bash +npm i -g tfx-cli + +tfx extension create --manifest-globs vss-extension.json +``` + +## Security notes +- No client secret is embedded in source. +- In production, use backend + PKCE + secure token handling. +- This implementation stores token and local refs in localStorage for MVP only. + +## Required config before real BC sync +Edit `auth.js` and set: +- `AUTH_CONFIG.clientId` + +Then set BC config in localStorage (or extend settings UI): +- `tenantId` +- `companyId` +- `employeeId` (optional per your BC setup) diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..4bd30b4 --- /dev/null +++ b/TERMS.md @@ -0,0 +1,10 @@ +# Terms of Use + +Provided as-is, without warranty. Use at your own risk. + +You are responsible for: +- complying with your organization's policies, +- validating Business Central mappings, +- and ensuring proper access controls. + +No liability for data loss, service interruption, or indirect damages. diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..0998dd2 --- /dev/null +++ b/auth.js @@ -0,0 +1,69 @@ +const AUTH_CONFIG = { + clientId: '', + tenant: 'common', + redirectUri: window.location.origin + window.location.pathname, + scope: 'https://api.businesscentral.dynamics.com/Financials.ReadWrite.All offline_access openid profile' +}; + +const AUTH_STORAGE_KEY = 'bc_auth_token'; + +function setAuthStatus(msg) { + const el = document.getElementById('auth-status'); + if (el) el.textContent = msg; +} + +function storeAuthToken(tokenObj) { + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(tokenObj)); +} + +function readAuthToken() { + try { return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || 'null'); } + catch { return null; } +} + +function getStoredAuthToken() { + const tok = readAuthToken(); + if (!tok) return null; + if (Date.now() >= tok.expiresAt) return null; + return tok.accessToken; +} + +function isTokenExpired() { + const tok = readAuthToken(); + return !tok || Date.now() >= tok.expiresAt; +} + +function decodeJwtExp(token) { + try { + const [, payload] = token.split('.'); + const json = JSON.parse(atob(payload.replace(/-/g,'+').replace(/_/g,'/'))); + return (json.exp || 0) * 1000; + } catch { return Date.now() + 300000; } +} + +function startOAuthPKCE() { + if (!AUTH_CONFIG.clientId) throw new Error('Set AUTH_CONFIG.clientId in auth.js'); + // Minimal implicit-style fallback for demo (no secret in client). Real prod should use backend + PKCE. + const state = Math.random().toString(36).slice(2); + sessionStorage.setItem('bc_oauth_state', state); + const authUrl = new URL(`https://login.microsoftonline.com/${AUTH_CONFIG.tenant}/oauth2/v2.0/authorize`); + authUrl.searchParams.set('client_id', AUTH_CONFIG.clientId); + authUrl.searchParams.set('response_type', 'token'); + authUrl.searchParams.set('redirect_uri', AUTH_CONFIG.redirectUri); + authUrl.searchParams.set('response_mode', 'fragment'); + authUrl.searchParams.set('scope', AUTH_CONFIG.scope); + authUrl.searchParams.set('state', state); + window.location.href = authUrl.toString(); +} + +function tryHandleOAuthRedirect() { + if (!window.location.hash.includes('access_token=')) return false; + const hash = new URLSearchParams(window.location.hash.slice(1)); + const token = hash.get('access_token'); + const state = hash.get('state'); + if (!token || state !== sessionStorage.getItem('bc_oauth_state')) return false; + const expiresAt = decodeJwtExp(token); + storeAuthToken({ accessToken: token, expiresAt }); + window.history.replaceState({}, document.title, window.location.pathname + window.location.search); + return true; +} diff --git a/bc-api.js b/bc-api.js new file mode 100644 index 0000000..ac483a0 --- /dev/null +++ b/bc-api.js @@ -0,0 +1,81 @@ +const BC_CFG_KEY = 'bc_config'; +const LOCAL_ENTRIES_KEY = 'bc_local_entries'; + +function getBCConfig() { + const fromStorage = JSON.parse(localStorage.getItem(BC_CFG_KEY) || 'null'); + return fromStorage || { + tenantId: '', + environment: 'production', + companyId: '', + employeeId: '', + unitOfMeasureCode: 'HOUR' + }; +} + +function saveBCConfig(cfg) { localStorage.setItem(BC_CFG_KEY, JSON.stringify(cfg)); } + +function bcBase(cfg) { + return `https://api.businesscentral.dynamics.com/v2.0/${cfg.tenantId}/${cfg.environment}/api/v2.0/companies(${cfg.companyId})`; +} + +async function bcFetch(path, options = {}) { + const token = getStoredAuthToken(); + if (!token) throw new Error('Not authenticated'); + const cfg = getBCConfig(); + if (!cfg.tenantId || !cfg.companyId) throw new Error('BC config missing tenantId/companyId'); + const res = await fetch(`${bcBase(cfg)}${path}`, { + ...options, + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...(options.headers || {}) } + }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { const j = await res.json(); msg = j.error?.message || msg; } catch {} + throw new Error(msg); + } + return res.status === 204 ? null : res.json(); +} + +async function createTimeEntryInBC(entry) { + const cfg = getBCConfig(); + const body = { + employeeId: cfg.employeeId || undefined, + date: entry.date, + quantity: entry.quantity, + jobNumber: entry.jobNumber || '', + jobTaskNumber: '', + unitOfMeasureCode: cfg.unitOfMeasureCode || 'HOUR', + description: entry.description || '' + }; + const created = await bcFetch('/timeRegistrationEntries', { method: 'POST', body: JSON.stringify(body) }); + return created; +} + +function saveLocalReference(workItemId, ref) { + const all = JSON.parse(localStorage.getItem(LOCAL_ENTRIES_KEY) || '{}'); + if (!all[workItemId]) all[workItemId] = []; + all[workItemId].push(ref); + localStorage.setItem(LOCAL_ENTRIES_KEY, JSON.stringify(all)); +} + +function getLocalReferences(workItemId) { + const all = JSON.parse(localStorage.getItem(LOCAL_ENTRIES_KEY) || '{}'); + return all[workItemId] || []; +} + +async function getTimeEntriesForWorkItem(workItemId) { + // Prefer BC fetch if configured; fallback local refs. + try { + const data = await bcFetch('/timeRegistrationEntries'); + const values = data.value || []; + return values.filter(e => (e.description || '').includes(`#${workItemId}`)).map(e => ({ + id: e.id, + date: e.date, + quantity: Number(e.quantity || 0), + description: e.description || '', + jobNumber: e.jobNumber || '', + syncStatus: 'synced' + })); + } catch { + return getLocalReferences(workItemId); + } +} diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1d4990565b8f2371111d1cad590e54bb1503c9 GIT binary patch literal 331 zcmeAS@N?(olHy`uVBq!ia0vp^Ng&L@1SD0CPmE+>U=;FnaSW-L^Y)x0CxZbG!^Ttd xE^w}DvF$&gz+_bX?)x=U=;FnaSW-L^Y)x0CxZbG!^Ttd xE^w}DvF$&gz+_bX?)x=)R-U|`vJ zYTgB|SzV78vM4D$uMw}fT*u(@jagxnJj2Al3>|OS9a8KWlzuY`+~jXKF)B53L*v2^ Z#>ehm%NkD?J_M#E22WQ%mvv4FO#pW=;>rL3 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..aaaed2e --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "devops-bc-timetracker", + "version": "1.0.0", + "description": "Azure DevOps work item extension for Business Central time tracking", + "scripts": { + "test": "node test/validate.js", + "package": "tfx extension create --manifest-globs vss-extension.json" + }, + "devDependencies": {} +} diff --git a/test/validate.js b/test/validate.js new file mode 100644 index 0000000..3843964 --- /dev/null +++ b/test/validate.js @@ -0,0 +1,12 @@ +const fs = require('fs'); +const required = ['vss-extension.json','time-tracker.html','time-tracker.css','time-tracker.js','bc-api.js','auth.js','README.md','PRIVACY.md','TERMS.md','img/logo.png','img/icon.png','img/preview.png']; +for (const f of required) { + if (!fs.existsSync(f)) throw new Error(`Missing ${f}`); +} +const manifest = JSON.parse(fs.readFileSync('vss-extension.json','utf8')); +if (!manifest.contributions?.length) throw new Error('No contributions'); +if (!manifest.scopes.includes('vso.work_write')) throw new Error('Missing work write scope'); +for (const f of ['time-tracker.js','bc-api.js','auth.js']) { + new Function(fs.readFileSync(f,'utf8')); +} +console.log('Validation passed'); diff --git a/time-tracker.css b/time-tracker.css new file mode 100644 index 0000000..69abf85 --- /dev/null +++ b/time-tracker.css @@ -0,0 +1,17 @@ +body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 12px; color: #222; } +#app { max-width: 920px; } +.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin-bottom: 12px; } +label { display:block; margin: 8px 0; } +input, textarea, button { font: inherit; padding: 8px; box-sizing: border-box; } +input, textarea { width: 100%; border: 1px solid #ccc; border-radius: 6px; } +button { border: none; border-radius: 6px; cursor: pointer; } +button.primary { background:#0078d4; color:#fff; } +.hidden { display:none !important; } +.error { background:#fde7e9; color:#a80000; border:1px solid #e81123; padding:10px; border-radius:6px; } +.success { background:#e6f4ea; color:#14532d; border:1px solid #22c55e; padding:10px; border-radius:6px; } +.muted { color:#666; } +.time-entry { border:1px solid #e5e5e5; border-radius:6px; padding:8px; margin:8px 0; } +.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; } +.badge-synced{ background:#dcfce7; color:#166534; } +.badge-pending{ background:#fef9c3; color:#854d0e; } +.badge-error{ background:#fee2e2; color:#991b1b; } diff --git a/time-tracker.html b/time-tracker.html new file mode 100644 index 0000000..a8699ca --- /dev/null +++ b/time-tracker.html @@ -0,0 +1,48 @@ + + + + + + Business Central Time Tracker + + + + +
+

Business Central Time Tracker

+ +
+ +

Not connected

+
+ + + + + + + + +
+ + + + + + diff --git a/time-tracker.js b/time-tracker.js new file mode 100644 index 0000000..6212c16 --- /dev/null +++ b/time-tracker.js @@ -0,0 +1,115 @@ +let currentWorkItemId = null; +let workItemService = null; + +function show(el, on=true){ document.getElementById(el).classList.toggle('hidden', !on); } +function setText(el,t){ const e=document.getElementById(el); if(e) e.textContent=t; } +function flash(id,msg){ setText(id,msg); show(id,true); setTimeout(()=>show(id,false),5000); } +function showError(m){ flash('error-message',m); } +function showSuccess(m){ flash('success-message',m); } + +function statusBadge(syncStatus) { + if (syncStatus === 'synced') return 'synced'; + if (syncStatus === 'error') return 'error'; + return 'pending'; +} + +function formatDate(s){ const d=new Date(s); return isNaN(d)?s:d.toLocaleDateString(); } + +async function initStandalone() { + currentWorkItemId = Number(new URLSearchParams(location.search).get('workItemId') || 0) || 12345; + document.getElementById('entry-date').valueAsDate = new Date(); + wireHandlers(); + if (tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); + refreshAuthUI(); + await loadTimeEntries(); +} + +function wireHandlers() { + document.getElementById('auth-button').addEventListener('click', () => { + try { startOAuthPKCE(); } catch (e) { showError(e.message); } + }); + + document.getElementById('time-entry-form').addEventListener('submit', handleSubmit); + document.getElementById('apply-filter').addEventListener('click', loadTimeEntries); +} + +function refreshAuthUI() { + const ok = !!getStoredAuthToken(); + show('auth-section', !ok); + show('entry-form', ok); + show('history-section', ok); + setAuthStatus(ok ? 'Connected' : 'Not connected'); +} + +async function handleSubmit(e) { + e.preventDefault(); + show('loading-indicator', true); + try { + const date = document.getElementById('entry-date').value; + const quantity = Number(document.getElementById('entry-hours').value); + const extra = document.getElementById('entry-description').value.trim(); + const jobNumber = document.getElementById('bc-job-number').value.trim(); + if (!date || !quantity || quantity <= 0) throw new Error('Date and positive hours required'); + + const workItemTitle = workItemService ? await workItemService.getFieldValue('System.Title') : 'Standalone test work item'; + const desc = `WI #${currentWorkItemId}: ${workItemTitle}${extra ? ' - ' + extra : ''}`; + + const localRef = { id: 'local_'+Date.now(), date, quantity, description: desc, jobNumber, syncStatus: 'pending' }; + saveLocalReference(currentWorkItemId, localRef); + + try { + const created = await createTimeEntryInBC({ date, quantity, description: desc, jobNumber }); + localRef.id = created.id || localRef.id; + localRef.syncStatus = 'synced'; + saveLocalReference(currentWorkItemId, localRef); + } catch (apiErr) { + localRef.syncStatus = 'error'; + saveLocalReference(currentWorkItemId, localRef); + showError('Saved locally; BC sync failed: ' + apiErr.message); + } + + e.target.reset(); + document.getElementById('entry-date').valueAsDate = new Date(); + showSuccess('Time entry logged'); + await loadTimeEntries(); + } catch (err) { + showError(err.message || String(err)); + } finally { + show('loading-indicator', false); + } +} + +async function loadTimeEntries() { + const list = document.getElementById('time-entries-list'); + const from = document.getElementById('filter-from').value; + const to = document.getElementById('filter-to').value; + let entries = await getTimeEntriesForWorkItem(currentWorkItemId); + if (from) entries = entries.filter(e => (e.date || '') >= from); + if (to) entries = entries.filter(e => (e.date || '') <= to); + const total = entries.reduce((a,e)=>a+Number(e.quantity||0),0); + document.getElementById('total-time').innerHTML = `Total: ${total.toFixed(2)} hours`; + if (!entries.length) { list.innerHTML = '

No entries yet.

'; return; } + list.innerHTML = entries.sort((a,b)=>String(b.date).localeCompare(String(a.date))).map(e => ` +
+
${formatDate(e.date)} — ${Number(e.quantity).toFixed(2)}h ${statusBadge(e.syncStatus)}
+
${e.description || ''}
+ ${e.jobNumber ? `
Job: ${e.jobNumber}
` : ''} +
+ `).join(''); +} + +(function boot(){ + if (window.VSS && window.VSS.init) { + VSS.init({ explicitNotifyLoaded: true, usePlatformStyles: true }); + VSS.ready(function(){ + VSS.require(["TFS/WorkItemTracking/Services"], function(WorkItemServices) { + WorkItemServices.WorkItemFormService.getService().then(function(svc) { + workItemService = svc; + svc.getId().then(function(id){ currentWorkItemId=id; wireHandlers(); refreshAuthUI(); loadTimeEntries(); VSS.notifyLoadSucceeded(); }); + }).catch(async ()=>{ await initStandalone(); VSS.notifyLoadSucceeded(); }); + }); + }); + } else { + initStandalone(); + } +})(); diff --git a/vss-extension.json b/vss-extension.json new file mode 100644 index 0000000..c425d8e --- /dev/null +++ b/vss-extension.json @@ -0,0 +1,43 @@ +{ + "manifestVersion": 1, + "id": "bc-timetracker", + "name": "Business Central Time Tracker", + "version": "1.0.0", + "publisher": "knowall-ai", + "description": "Track time on work items and sync to Microsoft Dynamics 365 Business Central", + "public": false, + "categories": ["Azure Boards"], + "targets": [{ "id": "Microsoft.VisualStudio.Services" }], + "icons": { "default": "img/logo.png" }, + "content": { + "details": { "path": "README.md" }, + "license": { "path": "TERMS.md" } + }, + "links": { + "support": { "uri": "https://github.com/knowall-ai/devops-bc-timetracker/issues" }, + "privacypolicy": { "uri": "https://github.com/knowall-ai/devops-bc-timetracker/blob/main/PRIVACY.md" } + }, + "scopes": ["vso.work", "vso.work_write"], + "contributions": [ + { + "id": "bc-timetracker-work-item-form", + "type": "ms.vss-work-web.work-item-form-group", + "description": "Track time and sync to Business Central", + "targets": ["ms.vss-work-web.work-item-form"], + "properties": { + "name": "Time Tracking", + "uri": "time-tracker.html", + "height": 500 + } + } + ], + "files": [ + { "path": "time-tracker.html", "addressable": true }, + { "path": "time-tracker.css", "addressable": true }, + { "path": "time-tracker.js", "addressable": true }, + { "path": "bc-api.js", "addressable": true }, + { "path": "auth.js", "addressable": true }, + { "path": "sdk/scripts", "addressable": true }, + { "path": "img", "addressable": true } + ] +} From 43ba062e43f73468425c50cf91fa0f690824e03e Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Fri, 22 May 2026 19:01:42 +0000 Subject: [PATCH 2/2] Add configurable PKCE Business Central setup --- README.md | 17 ++++---- auth.js | 109 ++++++++++++++++++++++++++++------------------ bc-api.js | 27 ++++++------ time-tracker.html | 14 ++++++ time-tracker.js | 39 ++++++++++++++--- 5 files changed, 138 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index bd76153..37a754d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Tracks time inside Azure DevOps work items and syncs entries to Microsoft Dynami - Work-item scoped history with total time - Date-range filtering - Sync status labels (`synced`, `pending`, `error`) -- OAuth connect button for Business Central access token flow +- OAuth 2.0 Authorization Code + PKCE connect flow for Business Central ## Files - `vss-extension.json` extension manifest @@ -35,10 +35,11 @@ tfx extension create --manifest-globs vss-extension.json - This implementation stores token and local refs in localStorage for MVP only. ## Required config before real BC sync -Edit `auth.js` and set: -- `AUTH_CONFIG.clientId` - -Then set BC config in localStorage (or extend settings UI): -- `tenantId` -- `companyId` -- `employeeId` (optional per your BC setup) +Use the in-extension settings form to set: +- Azure AD application/client ID +- Business Central tenant ID +- Business Central environment +- Business Central company ID +- Employee ID, if required by your BC setup + +The app uses browser-only PKCE for the MVP and does not embed a client secret. Production deployments should still consider a backend token broker depending on organizational policy. diff --git a/auth.js b/auth.js index 0998dd2..f364308 100644 --- a/auth.js +++ b/auth.js @@ -1,69 +1,94 @@ -const AUTH_CONFIG = { - clientId: '', - tenant: 'common', - redirectUri: window.location.origin + window.location.pathname, - scope: 'https://api.businesscentral.dynamics.com/Financials.ReadWrite.All offline_access openid profile' -}; - +const AUTH_CONFIG_KEY = 'bc_auth_config'; const AUTH_STORAGE_KEY = 'bc_auth_token'; +const PKCE_VERIFIER_KEY = 'bc_pkce_verifier'; + +function getAuthConfig() { + return JSON.parse(localStorage.getItem(AUTH_CONFIG_KEY) || 'null') || { + clientId: '', + tenant: 'common', + redirectUri: window.location.origin + window.location.pathname, + scope: 'https://api.businesscentral.dynamics.com/Financials.ReadWrite.All offline_access openid profile' + }; +} + +function saveAuthConfig(partial) { + const current = getAuthConfig(); + localStorage.setItem(AUTH_CONFIG_KEY, JSON.stringify({ ...current, ...partial })); +} function setAuthStatus(msg) { const el = document.getElementById('auth-status'); if (el) el.textContent = msg; } -function storeAuthToken(tokenObj) { - localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(tokenObj)); -} - +function storeAuthToken(tokenObj) { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(tokenObj)); } +function clearAuthToken() { localStorage.removeItem(AUTH_STORAGE_KEY); } function readAuthToken() { try { return JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) || 'null'); } catch { return null; } } - function getStoredAuthToken() { const tok = readAuthToken(); - if (!tok) return null; - if (Date.now() >= tok.expiresAt) return null; + if (!tok || Date.now() >= tok.expiresAt) return null; return tok.accessToken; } +function isTokenExpired() { return !getStoredAuthToken(); } -function isTokenExpired() { - const tok = readAuthToken(); - return !tok || Date.now() >= tok.expiresAt; +function base64Url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } - -function decodeJwtExp(token) { - try { - const [, payload] = token.split('.'); - const json = JSON.parse(atob(payload.replace(/-/g,'+').replace(/_/g,'/'))); - return (json.exp || 0) * 1000; - } catch { return Date.now() + 300000; } +async function sha256Base64Url(text) { + const data = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64Url(new Uint8Array(digest)); +} +function randomVerifier() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64Url(bytes); } -function startOAuthPKCE() { - if (!AUTH_CONFIG.clientId) throw new Error('Set AUTH_CONFIG.clientId in auth.js'); - // Minimal implicit-style fallback for demo (no secret in client). Real prod should use backend + PKCE. +async function startOAuthPKCE() { + const cfg = getAuthConfig(); + if (!cfg.clientId) throw new Error('Set Azure AD Client ID in settings first'); const state = Math.random().toString(36).slice(2); + const verifier = randomVerifier(); sessionStorage.setItem('bc_oauth_state', state); - const authUrl = new URL(`https://login.microsoftonline.com/${AUTH_CONFIG.tenant}/oauth2/v2.0/authorize`); - authUrl.searchParams.set('client_id', AUTH_CONFIG.clientId); - authUrl.searchParams.set('response_type', 'token'); - authUrl.searchParams.set('redirect_uri', AUTH_CONFIG.redirectUri); - authUrl.searchParams.set('response_mode', 'fragment'); - authUrl.searchParams.set('scope', AUTH_CONFIG.scope); + sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier); + const authUrl = new URL(`https://login.microsoftonline.com/${cfg.tenant || 'common'}/oauth2/v2.0/authorize`); + authUrl.searchParams.set('client_id', cfg.clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', cfg.redirectUri || window.location.origin + window.location.pathname); + authUrl.searchParams.set('response_mode', 'query'); + authUrl.searchParams.set('scope', cfg.scope); authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', await sha256Base64Url(verifier)); + authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString(); } -function tryHandleOAuthRedirect() { - if (!window.location.hash.includes('access_token=')) return false; - const hash = new URLSearchParams(window.location.hash.slice(1)); - const token = hash.get('access_token'); - const state = hash.get('state'); - if (!token || state !== sessionStorage.getItem('bc_oauth_state')) return false; - const expiresAt = decodeJwtExp(token); - storeAuthToken({ accessToken: token, expiresAt }); - window.history.replaceState({}, document.title, window.location.pathname + window.location.search); +async function tryHandleOAuthRedirect() { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (!code) return false; + if (state !== sessionStorage.getItem('bc_oauth_state')) throw new Error('OAuth state mismatch'); + const cfg = getAuthConfig(); + const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY); + const tokenUrl = `https://login.microsoftonline.com/${cfg.tenant || 'common'}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + client_id: cfg.clientId, + grant_type: 'authorization_code', + code, + redirect_uri: cfg.redirectUri || window.location.origin + window.location.pathname, + code_verifier: verifier || '', + scope: cfg.scope + }); + const res = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); + if (!res.ok) throw new Error('OAuth token exchange failed: ' + await res.text()); + const json = await res.json(); + storeAuthToken({ accessToken: json.access_token, refreshToken: json.refresh_token || null, expiresAt: Date.now() + (json.expires_in || 3600) * 1000 }); + sessionStorage.removeItem(PKCE_VERIFIER_KEY); + window.history.replaceState({}, document.title, window.location.pathname); return true; } diff --git a/bc-api.js b/bc-api.js index ac483a0..e2e1f1f 100644 --- a/bc-api.js +++ b/bc-api.js @@ -2,8 +2,7 @@ const BC_CFG_KEY = 'bc_config'; const LOCAL_ENTRIES_KEY = 'bc_local_entries'; function getBCConfig() { - const fromStorage = JSON.parse(localStorage.getItem(BC_CFG_KEY) || 'null'); - return fromStorage || { + return JSON.parse(localStorage.getItem(BC_CFG_KEY) || 'null') || { tenantId: '', environment: 'production', companyId: '', @@ -12,10 +11,11 @@ function getBCConfig() { }; } -function saveBCConfig(cfg) { localStorage.setItem(BC_CFG_KEY, JSON.stringify(cfg)); } +function saveBCConfig(cfg) { localStorage.setItem(BC_CFG_KEY, JSON.stringify({ ...getBCConfig(), ...cfg })); } function bcBase(cfg) { - return `https://api.businesscentral.dynamics.com/v2.0/${cfg.tenantId}/${cfg.environment}/api/v2.0/companies(${cfg.companyId})`; + const company = encodeURIComponent(cfg.companyId); + return `https://api.businesscentral.dynamics.com/v2.0/${encodeURIComponent(cfg.tenantId)}/${encodeURIComponent(cfg.environment || 'production')}/api/v2.0/companies(${company})`; } async function bcFetch(path, options = {}) { @@ -41,38 +41,39 @@ async function createTimeEntryInBC(entry) { employeeId: cfg.employeeId || undefined, date: entry.date, quantity: entry.quantity, - jobNumber: entry.jobNumber || '', - jobTaskNumber: '', + jobNo: entry.jobNumber || undefined, + jobTaskNo: entry.jobTaskNumber || undefined, unitOfMeasureCode: cfg.unitOfMeasureCode || 'HOUR', description: entry.description || '' }; - const created = await bcFetch('/timeRegistrationEntries', { method: 'POST', body: JSON.stringify(body) }); - return created; + return bcFetch('/timeRegistrationEntries', { method: 'POST', body: JSON.stringify(body) }); } function saveLocalReference(workItemId, ref) { const all = JSON.parse(localStorage.getItem(LOCAL_ENTRIES_KEY) || '{}'); - if (!all[workItemId]) all[workItemId] = []; - all[workItemId].push(ref); + const key = String(workItemId); + if (!all[key]) all[key] = []; + const i = all[key].findIndex(e => e.clientId === ref.clientId || e.id === ref.id); + if (i >= 0) all[key][i] = ref; else all[key].push(ref); localStorage.setItem(LOCAL_ENTRIES_KEY, JSON.stringify(all)); } function getLocalReferences(workItemId) { const all = JSON.parse(localStorage.getItem(LOCAL_ENTRIES_KEY) || '{}'); - return all[workItemId] || []; + return all[String(workItemId)] || []; } async function getTimeEntriesForWorkItem(workItemId) { - // Prefer BC fetch if configured; fallback local refs. try { const data = await bcFetch('/timeRegistrationEntries'); const values = data.value || []; return values.filter(e => (e.description || '').includes(`#${workItemId}`)).map(e => ({ id: e.id, + clientId: e.id, date: e.date, quantity: Number(e.quantity || 0), description: e.description || '', - jobNumber: e.jobNumber || '', + jobNumber: e.jobNo || e.jobNumber || '', syncStatus: 'synced' })); } catch { diff --git a/time-tracker.html b/time-tracker.html index a8699ca..93e1ac5 100644 --- a/time-tracker.html +++ b/time-tracker.html @@ -11,8 +11,22 @@

Business Central Time Tracker

+
+

Business Central Settings

+
+ + + + + + + +
+
+
+

Not connected

diff --git a/time-tracker.js b/time-tracker.js index 6212c16..e4854ac 100644 --- a/time-tracker.js +++ b/time-tracker.js @@ -19,18 +19,47 @@ async function initStandalone() { currentWorkItemId = Number(new URLSearchParams(location.search).get('workItemId') || 0) || 12345; document.getElementById('entry-date').valueAsDate = new Date(); wireHandlers(); - if (tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); + if (await tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); refreshAuthUI(); await loadTimeEntries(); } function wireHandlers() { - document.getElementById('auth-button').addEventListener('click', () => { - try { startOAuthPKCE(); } catch (e) { showError(e.message); } + document.getElementById('auth-button').addEventListener('click', async () => { + try { await startOAuthPKCE(); } catch (e) { showError(e.message); } }); + document.getElementById('disconnect-button').addEventListener('click', () => { clearAuthToken(); refreshAuthUI(); showSuccess('Disconnected'); }); + document.getElementById('settings-form').addEventListener('submit', handleSettingsSave); document.getElementById('time-entry-form').addEventListener('submit', handleSubmit); document.getElementById('apply-filter').addEventListener('click', loadTimeEntries); + loadSettingsForm(); +} + + +function loadSettingsForm() { + const authCfg = getAuthConfig(); + const bcCfg = getBCConfig(); + document.getElementById('cfg-client-id').value = authCfg.clientId || ''; + document.getElementById('cfg-tenant-id').value = bcCfg.tenantId || authCfg.tenant || ''; + document.getElementById('cfg-environment').value = bcCfg.environment || 'production'; + document.getElementById('cfg-company-id').value = bcCfg.companyId || ''; + document.getElementById('cfg-employee-id').value = bcCfg.employeeId || ''; + document.getElementById('cfg-uom').value = bcCfg.unitOfMeasureCode || 'HOUR'; +} + +function handleSettingsSave(e) { + e.preventDefault(); + const tenant = document.getElementById('cfg-tenant-id').value.trim() || 'common'; + saveAuthConfig({ clientId: document.getElementById('cfg-client-id').value.trim(), tenant }); + saveBCConfig({ + tenantId: tenant, + environment: document.getElementById('cfg-environment').value.trim() || 'production', + companyId: document.getElementById('cfg-company-id').value.trim(), + employeeId: document.getElementById('cfg-employee-id').value.trim(), + unitOfMeasureCode: document.getElementById('cfg-uom').value.trim() || 'HOUR' + }); + showSuccess('Settings saved'); } function refreshAuthUI() { @@ -54,7 +83,7 @@ async function handleSubmit(e) { const workItemTitle = workItemService ? await workItemService.getFieldValue('System.Title') : 'Standalone test work item'; const desc = `WI #${currentWorkItemId}: ${workItemTitle}${extra ? ' - ' + extra : ''}`; - const localRef = { id: 'local_'+Date.now(), date, quantity, description: desc, jobNumber, syncStatus: 'pending' }; + const localRef = { clientId: 'local_'+Date.now(), id: 'local_'+Date.now(), date, quantity, description: desc, jobNumber, syncStatus: 'pending' }; saveLocalReference(currentWorkItemId, localRef); try { @@ -105,7 +134,7 @@ async function loadTimeEntries() { VSS.require(["TFS/WorkItemTracking/Services"], function(WorkItemServices) { WorkItemServices.WorkItemFormService.getService().then(function(svc) { workItemService = svc; - svc.getId().then(function(id){ currentWorkItemId=id; wireHandlers(); refreshAuthUI(); loadTimeEntries(); VSS.notifyLoadSucceeded(); }); + svc.getId().then(async function(id){ currentWorkItemId=id; wireHandlers(); if (await tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); refreshAuthUI(); loadTimeEntries(); VSS.notifyLoadSucceeded(); }); }).catch(async ()=>{ await initStandalone(); VSS.notifyLoadSucceeded(); }); }); });