9 Commits

Author SHA1 Message Date
ca521000bf Merge pull request 'feature/mcp-and-api-keys' (#11) from feature/mcp-and-api-keys into main
Reviewed-on: #11
2026-03-16 16:46:26 +00:00
simon.franken
a7ab55932f fix: review fixes for MCP and API key feature
- Remove spurious ALTER TABLE client_targets DROP DEFAULT from migration
  (Prisma schema drift side-effect unrelated to this PR)
- Surface revoke errors in ApiKeysPage UI via deleteApiKey.isError
- Fix UpdateProjectInput.color type to allow null, removing unsafe cast
  in the update_project MCP tool handler
2026-03-16 15:35:06 +01:00
simon.franken
64211e6a49 feat: add MCP endpoint and API key management
- Add ApiKey Prisma model (SHA-256 hash, prefix, lastUsedAt) with migration
- Implement ApiKeyService (create, list, delete, verify)
- Extend requireAuth middleware to accept sk_-prefixed API keys alongside JWTs
- Add GET/POST /api-keys routes for creating and revoking keys
- Add stateless Streamable HTTP MCP server at POST/GET /mcp exposing all 20
  time-tracking tools (clients, projects, time entries, timer, statistics,
  client targets and corrections)
- Frontend: ApiKey types, apiKeys API module, useApiKeys hook
- Frontend: ApiKeysPage with key table, one-time raw-key reveal modal, and
  inline revoke confirmation
- Wire /api-keys route and add API Keys link to Management dropdown in Navbar
2026-03-16 15:26:09 +01:00
cd03d8751e fix: timer widget blocks dialogs 2026-03-13 17:08:21 +00:00
1964f76f74 fix: add bg-gray-50 back to Layout 2026-03-13 17:07:32 +00:00
1f4e12298e fix 2026-03-13 17:46:53 +01:00
simon.franken
1049410fee adaption 2026-03-09 11:20:53 +01:00
c9bd0abf18 feat: include ongoing timer in today's tracked time on Dashboard
The 'Today' stat card now adds the running timer's elapsed seconds to
the total, so the displayed duration ticks up live alongside the timer
widget. The timer is only counted when it started today (timers carried
over from the previous day are excluded).

A pulsing green indicator dot is shown on the stat card value while the
timer is active, consistent with the balance widget treatment. The dot
is implemented via a new optional 'indicator' prop on StatCard so it
can be reused elsewhere without changing existing call sites.
2026-03-09 11:14:21 +01:00
7ec76e3e8e feat: include ongoing timer in balance calculation
The balance now accounts for any active timer whose project belongs to
the tracked client. computeBalance() fetches the user's OngoingTimer,
computes its elapsed seconds, and adds them to the matching period's
tracked seconds before running the balance formula — so both
currentPeriodTrackedSeconds and totalBalanceSeconds reflect the live
timer without requiring a schema change.

On the frontend, useClientTargets polls every 30 s while a timer is
running, and a pulsing green dot is shown next to the balance figure on
the Dashboard and Clients pages to signal the live contribution.
2026-03-09 10:59:39 +01:00
24 changed files with 1741 additions and 26 deletions

View File

@@ -8,6 +8,7 @@
"name": "timetracker-backend",
"version": "1.0.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5",
@@ -471,6 +472,367 @@
"node": ">=18"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -771,6 +1133,39 @@
"node": ">= 0.6"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -983,6 +1378,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1193,6 +1602,27 @@
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -1239,6 +1669,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
@@ -1292,6 +1740,28 @@
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1456,6 +1926,15 @@
"node": ">= 0.4"
}
},
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1494,6 +1973,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1503,6 +1991,18 @@
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1522,6 +2022,18 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1808,6 +2320,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -1832,6 +2353,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -1852,6 +2382,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -1993,6 +2532,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2003,6 +2551,55 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/router/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2092,6 +2689,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2321,6 +2939,27 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -2335,6 +2974,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@@ -10,6 +10,7 @@
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5",

View File

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "api_keys" (
"id" TEXT NOT NULL,
"name" VARCHAR(255) NOT NULL,
"key_hash" VARCHAR(64) NOT NULL,
"prefix" VARCHAR(16) NOT NULL,
"last_used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" VARCHAR(255) NOT NULL,
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
-- CreateIndex
CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id");
-- AddForeignKey
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -20,6 +20,7 @@ model User {
timeEntries TimeEntry[]
ongoingTimer OngoingTimer?
clientTargets ClientTarget[]
apiKeys ApiKey[]
@@map("users")
}
@@ -151,3 +152,18 @@ model Session {
@@map("sessions")
}
model ApiKey {
id String @id @default(uuid())
name String @db.VarChar(255)
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hex
prefix String @db.VarChar(16) // first chars of raw key for display
lastUsedAt DateTime? @map("last_used_at")
createdAt DateTime @default(now()) @map("created_at")
userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("api_keys")
}

View File

@@ -13,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes";
import clientTargetRoutes from "./routes/clientTarget.routes";
import apiKeyRoutes from "./routes/apiKey.routes";
import mcpRoutes from "./routes/mcp.routes";
async function main() {
// Validate configuration
@@ -70,6 +72,8 @@ async function main() {
app.use("/time-entries", timeEntryRoutes);
app.use("/timer", timerRoutes);
app.use("/client-targets", clientTargetRoutes);
app.use("/api-keys", apiKeyRoutes);
app.use("/mcp", mcpRoutes);
// Error handling
app.use(notFoundHandler);

View File

@@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { verifyBackendJwt } from '../auth/jwt';
import { ApiKeyService } from '../services/apiKey.service';
const apiKeyService = new ApiKeyService();
export async function requireAuth(
req: AuthenticatedRequest,
@@ -17,11 +20,33 @@ export async function requireAuth(
return next();
}
// 2. Bearer JWT auth (iOS / native clients)
// 2. Bearer token auth (JWT or API key)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
// 2a. API key — detected by the "sk_" prefix
if (token.startsWith('sk_')) {
try {
const user = await apiKeyService.verify(token);
if (!user) {
console.warn(`${tag} -> API key verification failed: key not found`);
res.status(401).json({ error: 'Unauthorized: invalid API key' });
return;
}
req.user = user;
console.log(`${tag} -> API key auth OK (user: ${req.user.id})`);
return next();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`${tag} -> API key verification error: ${message}`);
res.status(401).json({ error: `Unauthorized: ${message}` });
return;
}
}
// 2b. JWT (iOS / native clients)
try {
req.user = verifyBackendJwt(token);
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);

View File

@@ -0,0 +1,51 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth';
import { validateBody, validateParams } from '../middleware/validation';
import { ApiKeyService } from '../services/apiKey.service';
import { CreateApiKeySchema, IdSchema } from '../schemas';
import type { AuthenticatedRequest } from '../types';
const router = Router();
const apiKeyService = new ApiKeyService();
// GET /api-keys - List user's API keys
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const keys = await apiKeyService.list(req.user!.id);
res.json(keys);
} catch (error) {
next(error);
}
});
// POST /api-keys - Create a new API key
router.post(
'/',
requireAuth,
validateBody(CreateApiKeySchema),
async (req: AuthenticatedRequest, res, next) => {
try {
const created = await apiKeyService.create(req.user!.id, req.body.name);
res.status(201).json(created);
} catch (error) {
next(error);
}
}
);
// DELETE /api-keys/:id - Revoke an API key
router.delete(
'/:id',
requireAuth,
validateParams(IdSchema),
async (req: AuthenticatedRequest, res, next) => {
try {
await apiKeyService.delete(req.params.id, req.user!.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
export default router;

View File

@@ -0,0 +1,455 @@
import { Router, Request, Response } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { requireAuth } from '../middleware/auth';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { ClientService } from '../services/client.service';
import { ProjectService } from '../services/project.service';
import { TimeEntryService } from '../services/timeEntry.service';
import { TimerService } from '../services/timer.service';
import { ClientTargetService } from '../services/clientTarget.service';
const router = Router();
// Service instances — shared, stateless
const clientService = new ClientService();
const projectService = new ProjectService();
const timeEntryService = new TimeEntryService();
const timerService = new TimerService();
const clientTargetService = new ClientTargetService();
/**
* Build and return a fresh stateless McpServer pre-populated with all tools
* scoped to the given authenticated user.
*/
function buildMcpServer(user: AuthenticatedUser): McpServer {
const server = new McpServer({
name: 'timetracker',
version: '1.0.0',
});
const userId = user.id;
// -------------------------------------------------------------------------
// Clients
// -------------------------------------------------------------------------
server.registerTool(
'list_clients',
{
description: 'List all clients for the authenticated user.',
inputSchema: {},
},
async () => {
const clients = await clientService.findAll(userId);
return { content: [{ type: 'text', text: JSON.stringify(clients, null, 2) }] };
}
);
server.registerTool(
'create_client',
{
description: 'Create a new client.',
inputSchema: {
name: z.string().min(1).max(255).describe('Client name'),
description: z.string().max(1000).optional().describe('Optional description'),
},
},
async ({ name, description }) => {
const client = await clientService.create(userId, { name, description });
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
}
);
server.registerTool(
'update_client',
{
description: 'Update an existing client.',
inputSchema: {
id: z.string().uuid().describe('Client ID'),
name: z.string().min(1).max(255).optional().describe('New name'),
description: z.string().max(1000).optional().describe('New description'),
},
},
async ({ id, name, description }) => {
const client = await clientService.update(id, userId, { name, description });
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
}
);
server.registerTool(
'delete_client',
{
description: 'Soft-delete a client (and its projects).',
inputSchema: {
id: z.string().uuid().describe('Client ID'),
},
},
async ({ id }) => {
await clientService.delete(id, userId);
return { content: [{ type: 'text', text: `Client ${id} deleted.` }] };
}
);
// -------------------------------------------------------------------------
// Projects
// -------------------------------------------------------------------------
server.registerTool(
'list_projects',
{
description: 'List all projects, optionally filtered by clientId.',
inputSchema: {
clientId: z.string().uuid().optional().describe('Filter by client ID'),
},
},
async ({ clientId }) => {
const projects = await projectService.findAll(userId, clientId);
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
}
);
server.registerTool(
'create_project',
{
description: 'Create a new project under a client.',
inputSchema: {
name: z.string().min(1).max(255).describe('Project name'),
clientId: z.string().uuid().describe('Client ID the project belongs to'),
description: z.string().max(1000).optional().describe('Optional description'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().describe('Hex color code, e.g. #FF5733'),
},
},
async ({ name, clientId, description, color }) => {
const project = await projectService.create(userId, { name, clientId, description, color });
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
}
);
server.registerTool(
'update_project',
{
description: 'Update an existing project.',
inputSchema: {
id: z.string().uuid().describe('Project ID'),
name: z.string().min(1).max(255).optional().describe('New name'),
description: z.string().max(1000).optional().describe('New description'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).nullable().optional().describe('Hex color or null to clear'),
clientId: z.string().uuid().optional().describe('Move project to a different client'),
},
},
async ({ id, name, description, color, clientId }) => {
const project = await projectService.update(id, userId, { name, description, color, clientId });
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
}
);
server.registerTool(
'delete_project',
{
description: 'Soft-delete a project.',
inputSchema: {
id: z.string().uuid().describe('Project ID'),
},
},
async ({ id }) => {
await projectService.delete(id, userId);
return { content: [{ type: 'text', text: `Project ${id} deleted.` }] };
}
);
// -------------------------------------------------------------------------
// Time entries
// -------------------------------------------------------------------------
server.registerTool(
'list_time_entries',
{
description: 'List time entries with optional filters. Returns paginated results.',
inputSchema: {
startDate: z.string().datetime().optional().describe('Filter entries starting at or after this ISO datetime'),
endDate: z.string().datetime().optional().describe('Filter entries starting at or before this ISO datetime'),
projectId: z.string().uuid().optional().describe('Filter by project ID'),
clientId: z.string().uuid().optional().describe('Filter by client ID'),
page: z.number().int().min(1).optional().default(1).describe('Page number (default 1)'),
limit: z.number().int().min(1).max(100).optional().default(50).describe('Results per page (max 100, default 50)'),
},
},
async (filters) => {
const result = await timeEntryService.findAll(userId, filters);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
);
server.registerTool(
'create_time_entry',
{
description: 'Create a manual time entry.',
inputSchema: {
projectId: z.string().uuid().describe('Project ID'),
startTime: z.string().datetime().describe('Start time as ISO datetime string'),
endTime: z.string().datetime().describe('End time as ISO datetime string'),
breakMinutes: z.number().int().min(0).optional().describe('Break duration in minutes (default 0)'),
description: z.string().max(1000).optional().describe('Optional description'),
},
},
async ({ projectId, startTime, endTime, breakMinutes, description }) => {
const entry = await timeEntryService.create(userId, { projectId, startTime, endTime, breakMinutes, description });
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'update_time_entry',
{
description: 'Update an existing time entry.',
inputSchema: {
id: z.string().uuid().describe('Time entry ID'),
startTime: z.string().datetime().optional().describe('New start time'),
endTime: z.string().datetime().optional().describe('New end time'),
breakMinutes: z.number().int().min(0).optional().describe('New break duration in minutes'),
description: z.string().max(1000).optional().describe('New description'),
projectId: z.string().uuid().optional().describe('Move to a different project'),
},
},
async ({ id, ...data }) => {
const entry = await timeEntryService.update(id, userId, data);
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'delete_time_entry',
{
description: 'Delete a time entry.',
inputSchema: {
id: z.string().uuid().describe('Time entry ID'),
},
},
async ({ id }) => {
await timeEntryService.delete(id, userId);
return { content: [{ type: 'text', text: `Time entry ${id} deleted.` }] };
}
);
server.registerTool(
'get_statistics',
{
description: 'Get aggregated time-tracking statistics, grouped by project and client.',
inputSchema: {
startDate: z.string().datetime().optional().describe('Filter from this ISO datetime'),
endDate: z.string().datetime().optional().describe('Filter until this ISO datetime'),
projectId: z.string().uuid().optional().describe('Filter by project ID'),
clientId: z.string().uuid().optional().describe('Filter by client ID'),
},
},
async (filters) => {
const stats = await timeEntryService.getStatistics(userId, filters);
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
}
);
// -------------------------------------------------------------------------
// Timer
// -------------------------------------------------------------------------
server.registerTool(
'get_timer',
{
description: 'Get the current running timer, or null if none is active.',
inputSchema: {},
},
async () => {
const timer = await timerService.getOngoingTimer(userId);
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
}
);
server.registerTool(
'start_timer',
{
description: 'Start a new timer. Fails if a timer is already running.',
inputSchema: {
projectId: z.string().uuid().optional().describe('Assign the timer to a project (can be set later)'),
},
},
async ({ projectId }) => {
const timer = await timerService.start(userId, { projectId });
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
}
);
server.registerTool(
'stop_timer',
{
description: 'Stop the running timer and save it as a time entry. A project must be assigned.',
inputSchema: {
projectId: z.string().uuid().optional().describe('Assign/override the project before stopping'),
},
},
async ({ projectId }) => {
const entry = await timerService.stop(userId, { projectId });
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
}
);
server.registerTool(
'cancel_timer',
{
description: 'Cancel the running timer without saving a time entry.',
inputSchema: {},
},
async () => {
await timerService.cancel(userId);
return { content: [{ type: 'text', text: 'Timer cancelled.' }] };
}
);
// -------------------------------------------------------------------------
// Client targets
// -------------------------------------------------------------------------
server.registerTool(
'list_client_targets',
{
description: 'List all client hour targets with computed balance for each period.',
inputSchema: {},
},
async () => {
const targets = await clientTargetService.findAll(userId);
return { content: [{ type: 'text', text: JSON.stringify(targets, null, 2) }] };
}
);
server.registerTool(
'create_client_target',
{
description: 'Create a new hour target for a client.',
inputSchema: {
clientId: z.string().uuid().describe('Client ID'),
targetHours: z.number().positive().max(168).describe('Target hours per period'),
periodType: z.enum(['weekly', 'monthly']).describe('Period type: weekly or monthly'),
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).describe('Working days, e.g. ["MON","TUE","WED","THU","FRI"]'),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date in YYYY-MM-DD format'),
},
},
async (data) => {
const target = await clientTargetService.create(userId, data);
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
}
);
server.registerTool(
'update_client_target',
{
description: 'Update an existing client hour target.',
inputSchema: {
id: z.string().uuid().describe('Target ID'),
targetHours: z.number().positive().max(168).optional().describe('New target hours per period'),
periodType: z.enum(['weekly', 'monthly']).optional().describe('New period type'),
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).optional().describe('New working days'),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('New start date in YYYY-MM-DD'),
},
},
async ({ id, ...data }) => {
const target = await clientTargetService.update(id, userId, data);
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
}
);
server.registerTool(
'delete_client_target',
{
description: 'Delete a client hour target.',
inputSchema: {
id: z.string().uuid().describe('Target ID'),
},
},
async ({ id }) => {
await clientTargetService.delete(id, userId);
return { content: [{ type: 'text', text: `Client target ${id} deleted.` }] };
}
);
server.registerTool(
'add_target_correction',
{
description: 'Add a manual hour correction to a client target (e.g. for holidays or overtime carry-over).',
inputSchema: {
targetId: z.string().uuid().describe('Client target ID'),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date of correction in YYYY-MM-DD format'),
hours: z.number().min(-1000).max(1000).describe('Hours to add (negative to deduct)'),
description: z.string().max(255).optional().describe('Optional reason for the correction'),
},
},
async ({ targetId, date, hours, description }) => {
const correction = await clientTargetService.addCorrection(targetId, userId, { date, hours, description });
return { content: [{ type: 'text', text: JSON.stringify(correction, null, 2) }] };
}
);
server.registerTool(
'delete_target_correction',
{
description: 'Delete a manual hour correction from a client target.',
inputSchema: {
targetId: z.string().uuid().describe('Client target ID'),
correctionId: z.string().uuid().describe('Correction ID'),
},
},
async ({ targetId, correctionId }) => {
await clientTargetService.deleteCorrection(targetId, correctionId, userId);
return { content: [{ type: 'text', text: `Correction ${correctionId} deleted.` }] };
}
);
return server;
}
// ---------------------------------------------------------------------------
// Route handler — one fresh McpServer + transport per request (stateless)
// ---------------------------------------------------------------------------
async function handleMcpRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const user = req.user!;
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const mcpServer = buildMcpServer(user);
// Ensure the server is cleaned up when the response finishes
res.on('close', () => {
transport.close().catch(() => undefined);
mcpServer.close().catch(() => undefined);
});
await mcpServer.connect(transport);
await transport.handleRequest(req as unknown as Request, res, req.body);
}
// GET /mcp — SSE stream for server-initiated messages
router.get('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
handleMcpRequest(req, res).catch((err) => {
console.error('[MCP] GET error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
});
});
// POST /mcp — JSON-RPC requests
router.post('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
handleMcpRequest(req, res).catch((err) => {
console.error('[MCP] POST error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
});
});
// DELETE /mcp — session termination (stateless: always 405)
router.delete('/', (_req, res: Response) => {
res.status(405).json({ error: 'Sessions are not supported (stateless mode)' });
});
export default router;

View File

@@ -95,3 +95,7 @@ export const CreateCorrectionSchema = z.object({
hours: z.number().min(-1000).max(1000),
description: z.string().max(255).optional(),
});
export const CreateApiKeySchema = z.object({
name: z.string().min(1).max(255),
});

View File

@@ -0,0 +1,99 @@
import { createHash, randomUUID } from 'crypto';
import { prisma } from '../prisma/client';
import { NotFoundError } from '../errors/AppError';
import type { AuthenticatedUser } from '../types';
const KEY_PREFIX_LENGTH = 12; // chars shown in UI
function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
function generateRawKey(): string {
return `sk_${randomUUID().replace(/-/g, '')}`;
}
export interface ApiKeyListItem {
id: string;
name: string;
prefix: string;
createdAt: string;
lastUsedAt: string | null;
}
export interface CreatedApiKey {
id: string;
name: string;
prefix: string;
rawKey: string; // returned once only
createdAt: string;
}
export class ApiKeyService {
async create(userId: string, name: string): Promise<CreatedApiKey> {
const rawKey = generateRawKey();
const keyHash = hashKey(rawKey);
const prefix = rawKey.slice(0, KEY_PREFIX_LENGTH);
const record = await prisma.apiKey.create({
data: { userId, name, keyHash, prefix },
});
return {
id: record.id,
name: record.name,
prefix: record.prefix,
rawKey,
createdAt: record.createdAt.toISOString(),
};
}
async list(userId: string): Promise<ApiKeyListItem[]> {
const keys = await prisma.apiKey.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return keys.map((k) => ({
id: k.id,
name: k.name,
prefix: k.prefix,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
}));
}
async delete(id: string, userId: string): Promise<void> {
const existing = await prisma.apiKey.findFirst({ where: { id, userId } });
if (!existing) {
throw new NotFoundError('API key not found');
}
await prisma.apiKey.delete({ where: { id } });
}
/**
* Verify a raw API key string. Returns the owning user or null.
* Updates lastUsedAt on success.
*/
async verify(rawKey: string): Promise<AuthenticatedUser | null> {
const keyHash = hashKey(rawKey);
const record = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!record) return null;
// Update lastUsedAt in the background — don't await to keep latency low
prisma.apiKey
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
id: record.user.id,
username: record.user.username,
fullName: record.user.fullName,
email: record.user.email,
};
}
}

View File

@@ -212,6 +212,8 @@ export interface ClientTargetWithBalance {
currentPeriodTrackedSeconds: number;
currentPeriodTargetSeconds: number;
periods: PeriodBalance[];
/** True when an active timer is running for a project belonging to this client. */
hasOngoingTimer: boolean;
}
// ---------------------------------------------------------------------------
@@ -405,6 +407,32 @@ export class ClientTargetService {
const overallEnd = periods[periods.length - 1].end;
const today = new Date().toISOString().split('T')[0];
// Fetch active timer for this user (if any) and check if it belongs to this client
const ongoingTimer = await prisma.ongoingTimer.findUnique({
where: { userId: target.userId },
include: { project: { select: { clientId: true } } },
});
// Elapsed seconds from the active timer attributed to this client target.
// We only count it if the timer has a project assigned and that project
// belongs to the same client as this target.
let ongoingTimerSeconds = 0;
let ongoingTimerPeriodStart: string | null = null;
if (
ongoingTimer &&
ongoingTimer.projectId !== null &&
ongoingTimer.project?.clientId === target.clientId
) {
ongoingTimerSeconds = Math.floor(
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
);
// Determine which period the timer's start time falls into
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
ongoingTimerPeriodStart = timerPeriod.start;
}
// Fetch all time tracked for this client across the full range in one query
type TrackedRow = { period_start: string; tracked_seconds: bigint };
@@ -489,7 +517,13 @@ export class ClientTargetService {
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
: target.targetHours;
const trackedSeconds = trackedByPeriod.get(period.start) ?? 0;
// Add ongoing timer seconds to the period it started in (if it belongs to this client)
const timerContribution =
ongoingTimerPeriodStart !== null && period.start === ongoingTimerPeriodStart
? ongoingTimerSeconds
: 0;
const trackedSeconds = (trackedByPeriod.get(period.start) ?? 0) + timerContribution;
const correctionHours = correctionsByPeriod.get(period.start) ?? 0;
const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 0;
@@ -574,6 +608,7 @@ export class ClientTargetService {
? Math.round(currentPeriod.targetHours * 3600)
: Math.round(target.targetHours * 3600),
periods: periodBalances,
hasOngoingTimer: ongoingTimerSeconds > 0,
};
}
@@ -594,6 +629,7 @@ export class ClientTargetService {
currentPeriodTrackedSeconds: 0,
currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
periods: [],
hasOngoingTimer: false,
};
}
}

View File

@@ -31,7 +31,7 @@ export interface CreateProjectInput {
export interface UpdateProjectInput {
name?: string;
description?: string;
color?: string;
color?: string | null;
clientId?: string;
}

View File

@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
import { ClientsPage } from "./pages/ClientsPage";
import { ProjectsPage } from "./pages/ProjectsPage";
import { StatisticsPage } from "./pages/StatisticsPage";
import { ApiKeysPage } from "./pages/ApiKeysPage";
function App() {
return (
@@ -33,6 +34,7 @@ function App() {
<Route path="clients" element={<ClientsPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="statistics" element={<StatisticsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
</Route>
</Routes>
</AuthProvider>

View File

@@ -0,0 +1,18 @@
import apiClient from './client';
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
export const apiKeysApi = {
getAll: async (): Promise<ApiKey[]> => {
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
return data;
},
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
return data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/api-keys/${id}`);
},
};

View File

@@ -4,9 +4,9 @@ import { TimerWidget } from './TimerWidget';
export function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<div className="h-[100vh] w-[100vw] flex flex-col bg-gray-50">
<Navbar />
<main className="pt-4 pb-24">
<main className="pt-4 pb-8 grow overflow-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet />
</div>

View File

@@ -8,6 +8,7 @@ import {
LogOut,
ChevronDown,
Settings,
Key,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useState, useRef, useEffect } from "react";
@@ -40,6 +41,7 @@ export function Navbar() {
const managementItems = [
{ to: "/clients", label: "Clients", icon: Briefcase },
{ to: "/projects", label: "Projects", icon: FolderOpen },
{ to: "/api-keys", label: "API Keys", icon: Key },
];
return (

View File

@@ -3,25 +3,35 @@ interface StatCardProps {
label: string;
value: string;
color: 'blue' | 'green' | 'purple' | 'orange';
/** When true, renders a pulsing green dot to signal a live/active state. */
indicator?: boolean;
}
const colorClasses: Record<StatCardProps['color'], string> = {
const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600',
};
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
return (
<div className="card p-4">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" />
</div>
<div className="ml-4">
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{label}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<div className="flex items-center gap-2">
{indicator && (
<span
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
title="Timer running"
/>
)}
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
</div>

View File

@@ -147,7 +147,7 @@ export function TimerWidget() {
if (isLoading) {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
@@ -156,7 +156,7 @@ export function TimerWidget() {
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg z-50">
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
{ongoingTimer ? (
<>

View File

@@ -0,0 +1,34 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiKeysApi } from '@/api/apiKeys';
import type { CreateApiKeyInput } from '@/types';
export function useApiKeys() {
const queryClient = useQueryClient();
const { data: apiKeys, isLoading, error } = useQuery({
queryKey: ['apiKeys'],
queryFn: apiKeysApi.getAll,
});
const createApiKey = useMutation({
mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
},
});
const deleteApiKey = useMutation({
mutationFn: (id: string) => apiKeysApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
},
});
return {
apiKeys,
isLoading,
error,
createApiKey,
deleteApiKey,
};
}

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientTargetsApi } from '@/api/clientTargets';
import { useTimer } from '@/contexts/TimerContext';
import type {
CreateClientTargetInput,
UpdateClientTargetInput,
@@ -8,10 +9,13 @@ import type {
export function useClientTargets() {
const queryClient = useQueryClient();
const { ongoingTimer } = useTimer();
const { data: targets, isLoading, error } = useQuery({
queryKey: ['clientTargets'],
queryFn: clientTargetsApi.getAll,
// Poll every 30 s while a timer is running so the balance stays current
refetchInterval: ongoingTimer ? 30_000 : false,
});
const createTarget = useMutation({

View File

@@ -0,0 +1,243 @@
import { useState } from "react";
import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react";
import { useApiKeys } from "@/hooks/useApiKeys";
import type { CreatedApiKey } from "@/types";
export function ApiKeysPage() {
const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys();
const [showCreateModal, setShowCreateModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
function formatDate(dateStr: string | null) {
if (!dateStr) return "Never";
return new Date(dateStr).toLocaleString();
}
async function handleCreate() {
if (!newKeyName.trim()) return;
setCreateError(null);
try {
const key = await createApiKey.mutateAsync({ name: newKeyName.trim() });
setCreatedKey(key);
setNewKeyName("");
} catch (err) {
setCreateError(err instanceof Error ? err.message : "An error occurred");
}
}
async function handleCopyKey() {
if (!createdKey) return;
await navigator.clipboard.writeText(createdKey.rawKey);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
}
function handleCloseCreateModal() {
setShowCreateModal(false);
setCreatedKey(null);
setNewKeyName("");
setCreateError(null);
setCopiedKey(false);
}
async function handleRevoke(id: string) {
try {
await deleteApiKey.mutateAsync(id);
setRevokeConfirmId(null);
} catch (_err) {
// error rendered below the table row via deleteApiKey.error
}
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Key className="h-6 w-6 text-gray-600" />
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4" />
Create API Key
</button>
</div>
<p className="text-sm text-gray-500 mb-6">
API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint.
The raw key is only shown once at creation time store it securely.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error instanceof Error ? error.message : "Failed to load API keys"}
</div>
)}
{deleteApiKey.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{deleteApiKey.error instanceof Error
? deleteApiKey.error.message
: "Failed to revoke API key"}
</div>
)}
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
</div>
) : (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
<td className="px-4 py-3">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
{key.prefix}
</code>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-right">
{revokeConfirmId === key.id ? (
<div className="inline-flex items-center gap-2">
<span className="text-xs text-red-600">Revoke?</span>
<button
onClick={() => handleRevoke(key.id)}
disabled={deleteApiKey.isPending}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
>
Yes
</button>
<button
onClick={() => setRevokeConfirmId(null)}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevokeConfirmId(key.id)}
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
title="Revoke key"
>
<Trash2 className="h-4 w-4" />
<span>Revoke</span>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create API Key Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
</div>
<div className="px-6 py-5">
{createdKey ? (
/* One-time key reveal */
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Copy this key now. <strong>It will not be shown again.</strong>
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
{createdKey.rawKey}
</code>
<button
onClick={handleCopyKey}
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Copy to clipboard"
>
{copiedKey ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
</div>
</div>
) : (
/* Name input form */
<div className="space-y-4">
<div>
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
Key name
</label>
<input
id="key-name"
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="e.g. My Claude Agent"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
autoFocus
/>
</div>
{createError && (
<p className="text-red-600 text-sm">{createError}</p>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={handleCloseCreateModal}
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{createdKey ? "Done" : "Cancel"}
</button>
{!createdKey && (
<button
onClick={handleCreate}
disabled={!newKeyName.trim() || createApiKey.isPending}
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createApiKey.isPending ? "Creating..." : "Create"}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -316,6 +316,12 @@ function ClientTargetPanel({
<span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
</span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
{target!.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
</div>
<div className="flex items-center gap-1">
<button

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets";
import { useTimer } from "@/contexts/TimerContext";
import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard";
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
@@ -30,6 +31,7 @@ export function DashboardPage() {
});
const { targets } = useClientTargets();
const { ongoingTimer, elapsedSeconds } = useTimer();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
@@ -54,10 +56,17 @@ export function DashboardPage() {
}
};
const totalTodaySeconds =
const completedTodaySeconds =
todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0;
}, 0) ?? 0;
// Only add the running timer if it started today (not a timer left running from yesterday)
const timerStartedToday =
ongoingTimer !== null &&
new Date(ongoingTimer.startTime) >= startOfDay(today);
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue"
indicator={timerStartedToday}
/>
<StatCard
icon={Calendar}
@@ -132,19 +142,27 @@ export function DashboardPage() {
</p>
</div>
<div className="text-right">
<p
className={`text-sm font-bold ${
isEven
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
<div className="flex items-center justify-end gap-1.5">
{target.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
<p
className={`text-sm font-bold ${
isEven
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
</div>
<p className="text-xs text-gray-400">running balance</p>
</div>
</div>

View File

@@ -186,6 +186,8 @@ export interface ClientTargetWithBalance {
currentPeriodTrackedSeconds: number;
currentPeriodTargetSeconds: number;
periods: PeriodBalance[];
/** True when an active timer for a project belonging to this client is running. */
hasOngoingTimer: boolean;
}
export interface CreateClientTargetInput {
@@ -208,3 +210,19 @@ export interface CreateCorrectionInput {
hours: number;
description?: string;
}
export interface ApiKey {
id: string;
name: string;
prefix: string;
createdAt: string;
lastUsedAt: string | null;
}
export interface CreatedApiKey extends ApiKey {
rawKey: string; // returned only on creation
}
export interface CreateApiKeyInput {
name: string;
}