Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 42 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions TERMS.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions auth.js
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions bc-api.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Binary file added img/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
12 changes: 12 additions & 0 deletions test/validate.js
Original file line number Diff line number Diff line change
@@ -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');
17 changes: 17 additions & 0 deletions time-tracker.css
Original file line number Diff line number Diff line change
@@ -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; }
62 changes: 62 additions & 0 deletions time-tracker.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Business Central Time Tracker</title>
<link rel="stylesheet" href="time-tracker.css" />
<script src="sdk/scripts/VSS.SDK.min.js"></script>
</head>
<body>
<div id="app">
<h2>Business Central Time Tracker</h2>

<section id="settings-section" class="card">
<h3>Business Central Settings</h3>
<form id="settings-form">
<label>Azure AD Client ID<input type="text" id="cfg-client-id" placeholder="Application/client ID" /></label>
<label>Tenant ID<input type="text" id="cfg-tenant-id" placeholder="contoso.onmicrosoft.com or tenant GUID" /></label>
<label>Environment<input type="text" id="cfg-environment" placeholder="production" /></label>
<label>Company ID<input type="text" id="cfg-company-id" placeholder="Business Central company GUID" /></label>
<label>Employee ID<input type="text" id="cfg-employee-id" placeholder="Optional employee GUID" /></label>
<label>Unit of Measure<input type="text" id="cfg-uom" placeholder="HOUR" /></label>
<button type="submit">Save Settings</button>
</form>
</section>

<section id="auth-section" class="card">
<button id="auth-button" class="primary">Connect to Business Central</button>
<button id="disconnect-button" type="button">Disconnect</button>
<p id="auth-status" class="muted">Not connected</p>
</section>

<section id="entry-form" class="card hidden">
<h3>Log Time Entry</h3>
<form id="time-entry-form">
<label>Date<input type="date" id="entry-date" required /></label>
<label>Hours<input type="number" id="entry-hours" min="0.25" step="0.25" required /></label>
<label>Description<textarea id="entry-description" rows="3"></textarea></label>
<label>BC Job Number (optional)<input type="text" id="bc-job-number" /></label>
<button type="submit" class="primary">Log Time</button>
</form>
</section>

<section id="history-section" class="card hidden">
<h3>Time Entry History</h3>
<div id="total-time">Total: <strong>0.00 hours</strong></div>
<label>From <input type="date" id="filter-from"></label>
<label>To <input type="date" id="filter-to"></label>
<button id="apply-filter">Apply Filter</button>
<div id="time-entries-list"></div>
</section>

<div id="loading-indicator" class="hidden">Loading…</div>
<div id="error-message" class="hidden error"></div>
<div id="success-message" class="hidden success"></div>
</div>

<script src="auth.js"></script>
<script src="bc-api.js"></script>
<script src="time-tracker.js"></script>
</body>
</html>
Loading