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 @@ + + +
+ + +Not connected
+No entries yet.
'; return; } + list.innerHTML = entries.sort((a,b)=>String(b.date).localeCompare(String(a.date))).map(e => ` +