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..37a754d 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,45 @@ -# 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 2.0 Authorization Code + PKCE connect flow for Business Central + +## 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 +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/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..f364308 --- /dev/null +++ b/auth.js @@ -0,0 +1,94 @@ +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 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 || Date.now() >= tok.expiresAt) return null; + return tok.accessToken; +} +function isTokenExpired() { return !getStoredAuthToken(); } + +function base64Url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +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); +} + +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); + 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(); +} + +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 new file mode 100644 index 0000000..e2e1f1f --- /dev/null +++ b/bc-api.js @@ -0,0 +1,82 @@ +const BC_CFG_KEY = 'bc_config'; +const LOCAL_ENTRIES_KEY = 'bc_local_entries'; + +function getBCConfig() { + return JSON.parse(localStorage.getItem(BC_CFG_KEY) || 'null') || { + tenantId: '', + environment: 'production', + companyId: '', + employeeId: '', + unitOfMeasureCode: 'HOUR' + }; +} + +function saveBCConfig(cfg) { localStorage.setItem(BC_CFG_KEY, JSON.stringify({ ...getBCConfig(), ...cfg })); } + +function bcBase(cfg) { + 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 = {}) { + 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, + jobNo: entry.jobNumber || undefined, + jobTaskNo: entry.jobTaskNumber || undefined, + unitOfMeasureCode: cfg.unitOfMeasureCode || 'HOUR', + description: entry.description || '' + }; + return bcFetch('/timeRegistrationEntries', { method: 'POST', body: JSON.stringify(body) }); +} + +function saveLocalReference(workItemId, ref) { + const all = JSON.parse(localStorage.getItem(LOCAL_ENTRIES_KEY) || '{}'); + 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[String(workItemId)] || []; +} + +async function getTimeEntriesForWorkItem(workItemId) { + 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.jobNo || e.jobNumber || '', + syncStatus: 'synced' + })); + } catch { + return getLocalReferences(workItemId); + } +} diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 0000000..2f1d499 Binary files /dev/null and b/img/icon.png differ diff --git a/img/logo.png b/img/logo.png new file mode 100644 index 0000000..2f1d499 Binary files /dev/null and b/img/logo.png differ diff --git a/img/preview.png b/img/preview.png new file mode 100644 index 0000000..26dd589 Binary files /dev/null and b/img/preview.png differ 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..93e1ac5 --- /dev/null +++ b/time-tracker.html @@ -0,0 +1,62 @@ + + + + + + Business Central Time Tracker + + + + +
+

Business Central Time Tracker

+ +
+

Business Central Settings

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

Not connected

+
+ + + + + + + + +
+ + + + + + diff --git a/time-tracker.js b/time-tracker.js new file mode 100644 index 0000000..e4854ac --- /dev/null +++ b/time-tracker.js @@ -0,0 +1,144 @@ +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 (await tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); + refreshAuthUI(); + await loadTimeEntries(); +} + +function wireHandlers() { + 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() { + 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 = { clientId: 'local_'+Date.now(), 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(async function(id){ currentWorkItemId=id; wireHandlers(); if (await tryHandleOAuthRedirect()) showSuccess('Connected to Business Central'); 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 } + ] +}