Compare commits
22 Commits
4b0cfaa699
...
f758aa2fcd
| Author | SHA1 | Date | |
|---|---|---|---|
| f758aa2fcd | |||
| e51dd58a6b | |||
| 39d6ea00d9 | |||
| 48cd82ab4f | |||
| 062af3b2da | |||
| 1aac76af4a | |||
| f1f60ef685 | |||
| bb2e51cd0a | |||
| 946cd35832 | |||
| 1ca76b0fec | |||
| b3db7cbd7b | |||
| f218552d48 | |||
| 0d084cd546 | |||
| 5f23961f50 | |||
| 7e8e220e3b | |||
| ed180500a6 | |||
| 165b1b9c67 | |||
| 2534011506 | |||
| 4c0d8be018 | |||
| b66b433c08 | |||
| 7659b01614 | |||
| 4e49741dfa |
@@ -1,5 +1,10 @@
|
|||||||
APP_URL=
|
APP_URL=
|
||||||
OIDC_ISSUER_URL=
|
OIDC_ISSUER_URL=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_REDIRECT_URI=
|
||||||
|
OIDC_IOS_REDIRECT_URI=timetracker://oauth/callback
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
# Optional: dedicated signing secret for backend-issued JWTs (iOS auth).
|
||||||
|
# Falls back to SESSION_SECRET if not set.
|
||||||
|
JWT_SECRET=
|
||||||
API_URL=
|
API_URL=
|
||||||
578
backend/package-lock.json
generated
578
backend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -20,12 +21,285 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.17.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||||
@@ -43,6 +317,159 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
@@ -209,6 +636,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -216,6 +654,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.2.3",
|
"version": "25.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||||
@@ -316,6 +761,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -570,6 +1021,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -828,6 +1288,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1007,6 +1482,97 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/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/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
@@ -1423,6 +1989,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.17.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
45
backend/src/auth/jwt.ts
Normal file
45
backend/src/auth/jwt.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
import type { AuthenticatedUser } from '../types';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a backend-signed JWT for a native (iOS) client.
|
||||||
|
* The token is self-contained — no IDP call is needed to verify it.
|
||||||
|
*/
|
||||||
|
export function signBackendJwt(user: AuthenticatedUser): string {
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, config.jwt.secret, {
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
algorithm: 'HS256',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a backend-signed JWT and return the encoded user.
|
||||||
|
* Throws if the token is invalid or expired.
|
||||||
|
*/
|
||||||
|
export function verifyBackendJwt(token: string): AuthenticatedUser {
|
||||||
|
const payload = jwt.verify(token, config.jwt.secret, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as JwtPayload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
username: payload.username,
|
||||||
|
fullName: payload.fullName,
|
||||||
|
email: payload.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,15 +2,23 @@ import { Issuer, generators, Client, TokenSet } from 'openid-client';
|
|||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import type { AuthenticatedUser } from '../types';
|
import type { AuthenticatedUser } from '../types';
|
||||||
|
|
||||||
|
// Note: bearer-token (JWT) verification for native clients lives in auth/jwt.ts.
|
||||||
|
// This module is responsible solely for the OIDC protocol flows.
|
||||||
|
|
||||||
let oidcClient: Client | null = null;
|
let oidcClient: Client | null = null;
|
||||||
|
|
||||||
export async function initializeOIDC(): Promise<void> {
|
export async function initializeOIDC(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const issuer = await Issuer.discover(config.oidc.issuerUrl);
|
const issuer = await Issuer.discover(config.oidc.issuerUrl);
|
||||||
|
|
||||||
|
const redirectUris = [config.oidc.redirectUri];
|
||||||
|
if (config.oidc.iosRedirectUri) {
|
||||||
|
redirectUris.push(config.oidc.iosRedirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
oidcClient = new issuer.Client({
|
oidcClient = new issuer.Client({
|
||||||
client_id: config.oidc.clientId,
|
client_id: config.oidc.clientId,
|
||||||
redirect_uris: [config.oidc.redirectUri],
|
redirect_uris: redirectUris,
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
token_endpoint_auth_method: 'none', // PKCE flow - no client secret
|
token_endpoint_auth_method: 'none', // PKCE flow - no client secret
|
||||||
});
|
});
|
||||||
@@ -32,28 +40,44 @@ export function getOIDCClient(): Client {
|
|||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
state: string;
|
state: string;
|
||||||
nonce: string;
|
nonce: string | undefined;
|
||||||
|
redirectUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthSession(): AuthSession {
|
export function createAuthSession(redirectUri?: string): AuthSession {
|
||||||
|
const isNative = !!redirectUri;
|
||||||
return {
|
return {
|
||||||
codeVerifier: generators.codeVerifier(),
|
codeVerifier: generators.codeVerifier(),
|
||||||
state: generators.state(),
|
state: generators.state(),
|
||||||
nonce: generators.nonce(),
|
// Nonce is omitted for native/PKCE-only flows. PKCE itself binds the code
|
||||||
|
// exchange so nonce provides no additional security. Some providers also
|
||||||
|
// don't echo the nonce back in the ID token for public clients, which
|
||||||
|
// causes openid-client to throw a nonce mismatch error.
|
||||||
|
nonce: isNative ? undefined : generators.nonce(),
|
||||||
|
redirectUri,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthorizationUrl(session: AuthSession): string {
|
export function getAuthorizationUrl(session: AuthSession, redirectUri?: string): string {
|
||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
const codeChallenge = generators.codeChallenge(session.codeVerifier);
|
const codeChallenge = generators.codeChallenge(session.codeVerifier);
|
||||||
|
|
||||||
return client.authorizationUrl({
|
const params: Record<string, string> = {
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
state: session.state,
|
state: session.state,
|
||||||
nonce: session.nonce,
|
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (session.nonce) {
|
||||||
|
params.nonce = session.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUri) {
|
||||||
|
params.redirect_uri = redirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.authorizationUrl(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCallback(
|
export async function handleCallback(
|
||||||
@@ -62,56 +86,80 @@ export async function handleCallback(
|
|||||||
): Promise<TokenSet> {
|
): Promise<TokenSet> {
|
||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
|
|
||||||
const tokenSet = await client.callback(
|
const redirectUri = session.redirectUri || config.oidc.redirectUri;
|
||||||
config.oidc.redirectUri,
|
|
||||||
params,
|
const checks: Record<string, string | undefined> = {
|
||||||
{
|
|
||||||
code_verifier: session.codeVerifier,
|
code_verifier: session.codeVerifier,
|
||||||
state: session.state,
|
state: session.state,
|
||||||
nonce: session.nonce,
|
};
|
||||||
|
|
||||||
|
if (session.nonce) {
|
||||||
|
checks.nonce = session.nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenSet = await client.callback(
|
||||||
|
redirectUri,
|
||||||
|
params,
|
||||||
|
checks,
|
||||||
);
|
);
|
||||||
|
|
||||||
return tokenSet;
|
return tokenSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For native app flows the provider may return only an access token (no ID token)
|
||||||
|
// when the redirect_uri uses a custom scheme. client.grant() calls the token
|
||||||
|
// endpoint directly and does not attempt ID token validation.
|
||||||
|
export async function exchangeNativeCode(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const client = getOIDCClient();
|
||||||
|
|
||||||
|
const tokenSet = await client.grant({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tokenSet;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser> {
|
export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser> {
|
||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
|
|
||||||
const claims = tokenSet.claims();
|
// ID token claims (only available in web/full OIDC flow)
|
||||||
|
const idTokenClaims = tokenSet.id_token ? tokenSet.claims() : undefined;
|
||||||
|
|
||||||
// Try to get more detailed userinfo if available
|
// Always attempt userinfo; for native flows this is the sole source of claims.
|
||||||
let userInfo: Record<string, unknown> = {};
|
let userInfo: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
userInfo = await client.userinfo(tokenSet);
|
userInfo = await client.userinfo(tokenSet);
|
||||||
} catch {
|
} catch {
|
||||||
// Some providers don't support userinfo endpoint
|
if (!idTokenClaims) {
|
||||||
// We'll use the claims from the ID token
|
// No ID token and no userinfo — nothing to work with.
|
||||||
|
throw new Error('Unable to retrieve user info: userinfo endpoint failed and no ID token present');
|
||||||
|
}
|
||||||
|
// Web flow: fall back to ID token claims only
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = String(claims.sub);
|
const sub = String(userInfo.sub || idTokenClaims?.sub);
|
||||||
const username = String(userInfo.preferred_username || claims.preferred_username || claims.name || id);
|
const id = sub;
|
||||||
const email = String(userInfo.email || claims.email || '');
|
const username = String(
|
||||||
const fullName = String(userInfo.name || claims.name || '') || null;
|
userInfo.preferred_username ||
|
||||||
|
idTokenClaims?.preferred_username ||
|
||||||
|
userInfo.name ||
|
||||||
|
idTokenClaims?.name ||
|
||||||
|
id
|
||||||
|
);
|
||||||
|
const email = String(userInfo.email || idTokenClaims?.email || '');
|
||||||
|
const fullName = String(userInfo.name || idTokenClaims?.name || '') || null;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error('Email not provided by OIDC provider');
|
throw new Error('Email not provided by OIDC provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { id, username, fullName, email };
|
||||||
id,
|
|
||||||
username,
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const client = getOIDCClient();
|
|
||||||
await client.userinfo(tokenSet);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ export const config = {
|
|||||||
redirectUri:
|
redirectUri:
|
||||||
process.env.OIDC_REDIRECT_URI ||
|
process.env.OIDC_REDIRECT_URI ||
|
||||||
"http://localhost:3001/api/auth/callback",
|
"http://localhost:3001/api/auth/callback",
|
||||||
|
iosRedirectUri: process.env.OIDC_IOS_REDIRECT_URI || "timetracker://oauth/callback",
|
||||||
},
|
},
|
||||||
|
|
||||||
session: {
|
session: {
|
||||||
@@ -24,6 +25,13 @@ export const config = {
|
|||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
},
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
// Dedicated secret for backend-issued JWTs. Falls back to SESSION_SECRET so
|
||||||
|
// existing single-secret deployments work without any config change.
|
||||||
|
secret: process.env.JWT_SECRET || process.env.SESSION_SECRET || "default-secret-change-in-production",
|
||||||
|
expiresIn: 30 * 24 * 60 * 60, // 30 days in seconds
|
||||||
|
},
|
||||||
|
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.APP_URL || "http://localhost:5173",
|
origin: process.env.APP_URL || "http://localhost:5173",
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -1,19 +1,45 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { prisma } from '../prisma/client';
|
import { prisma } from '../prisma/client';
|
||||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||||
|
import { verifyBackendJwt } from '../auth/jwt';
|
||||||
|
|
||||||
export function requireAuth(
|
export async function requireAuth(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!req.session?.user) {
|
const tag = `[requireAuth] ${req.method} ${req.path}`;
|
||||||
res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
return;
|
// 1. Session-based auth (web frontend)
|
||||||
|
if (req.session?.user) {
|
||||||
|
console.log(`${tag} -> session auth OK (user: ${req.session.user.id})`);
|
||||||
|
req.user = req.session.user as AuthenticatedUser;
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = req.session.user as AuthenticatedUser;
|
// 2. Bearer JWT auth (iOS / native clients)
|
||||||
next();
|
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)}…)`);
|
||||||
|
try {
|
||||||
|
req.user = verifyBackendJwt(token);
|
||||||
|
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`${tag} -> JWT verification failed: ${message}`);
|
||||||
|
res.status(401).json({ error: `Unauthorized: ${message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader) {
|
||||||
|
console.warn(`${tag} -> Authorization header present but not a Bearer token: "${authHeader.slice(0, 30)}…"`);
|
||||||
|
} else {
|
||||||
|
console.warn(`${tag} -> No session and no Authorization header`);
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionalAuth(
|
export function optionalAuth(
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
createAuthSession,
|
createAuthSession,
|
||||||
getAuthorizationUrl,
|
getAuthorizationUrl,
|
||||||
handleCallback,
|
handleCallback,
|
||||||
|
exchangeNativeCode,
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
} from "../auth/oidc";
|
} from "../auth/oidc";
|
||||||
|
import { signBackendJwt } from "../auth/jwt";
|
||||||
import { requireAuth, syncUser } from "../middleware/auth";
|
import { requireAuth, syncUser } from "../middleware/auth";
|
||||||
import type { AuthenticatedRequest } from "../types";
|
import type { AuthenticatedRequest } from "../types";
|
||||||
|
import type { AuthSession } from "../auth/oidc";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -21,23 +24,55 @@ async function ensureOIDC() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short-lived store for native app OIDC sessions, keyed by state.
|
||||||
|
// Entries are cleaned up after 10 minutes regardless of use.
|
||||||
|
const nativeOidcSessions = new Map<string, { session: AuthSession; expiresAt: number }>();
|
||||||
|
const NATIVE_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function storeNativeSession(session: AuthSession): void {
|
||||||
|
nativeOidcSessions.set(session.state, {
|
||||||
|
session,
|
||||||
|
expiresAt: Date.now() + NATIVE_SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function popNativeSession(state: string): AuthSession | null {
|
||||||
|
const entry = nativeOidcSessions.get(state);
|
||||||
|
if (!entry) return null;
|
||||||
|
nativeOidcSessions.delete(state);
|
||||||
|
if (Date.now() > entry.expiresAt) return null;
|
||||||
|
return entry.session;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /auth/login - Initiate OIDC login flow
|
// GET /auth/login - Initiate OIDC login flow
|
||||||
router.get("/login", async (req, res) => {
|
router.get("/login", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureOIDC();
|
await ensureOIDC();
|
||||||
|
|
||||||
const session = createAuthSession();
|
const redirectUri = req.query.redirect_uri as string | undefined;
|
||||||
req.session.oidc = session;
|
console.log(`[auth/login] initiated (redirect_uri: ${redirectUri ?? '(web flow)'})`);
|
||||||
|
const session = createAuthSession(redirectUri);
|
||||||
|
|
||||||
const authorizationUrl = getAuthorizationUrl(session);
|
if (redirectUri) {
|
||||||
|
// Native app flow: store session by state so /auth/token can retrieve it
|
||||||
|
// without relying on the browser cookie jar.
|
||||||
|
storeNativeSession(session);
|
||||||
|
console.log(`[auth/login] native session stored (state: ${session.state})`);
|
||||||
|
} else {
|
||||||
|
// Web flow: store session in the cookie-backed server session as before.
|
||||||
|
req.session.oidc = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationUrl = getAuthorizationUrl(session, redirectUri);
|
||||||
|
console.log(`[auth/login] redirecting to IDP`);
|
||||||
res.redirect(authorizationUrl);
|
res.redirect(authorizationUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error);
|
console.error("[auth/login] error:", error);
|
||||||
res.status(500).json({ error: "Failed to initiate login" });
|
res.status(500).json({ error: "Failed to initiate login" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /auth/callback - OIDC callback handler
|
// GET /auth/callback - OIDC callback handler (web frontend only)
|
||||||
router.get("/callback", async (req, res) => {
|
router.get("/callback", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureOIDC();
|
await ensureOIDC();
|
||||||
@@ -88,4 +123,54 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
|
|||||||
res.json(req.user);
|
res.json(req.user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /auth/token - Exchange OIDC authorization code for a backend JWT (native app flow).
|
||||||
|
// The iOS app calls this after the OIDC redirect; it receives a backend-signed JWT which
|
||||||
|
// it then uses as a Bearer token for all subsequent API requests. The backend verifies
|
||||||
|
// this JWT locally — no per-request IDP call is needed.
|
||||||
|
router.post("/token", async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureOIDC();
|
||||||
|
|
||||||
|
const { code, state, redirect_uri } = req.body;
|
||||||
|
console.log(`[auth/token] received (state: ${state}, redirect_uri: ${redirect_uri}, code present: ${!!code})`);
|
||||||
|
|
||||||
|
if (!code || !state || !redirect_uri) {
|
||||||
|
const missing = ['code', 'state', 'redirect_uri'].filter(k => !req.body[k]);
|
||||||
|
console.warn(`[auth/token] missing parameters: ${missing.join(', ')}`);
|
||||||
|
res.status(400).json({ error: `Missing required parameters: ${missing.join(', ')}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcSession = popNativeSession(state);
|
||||||
|
if (!oidcSession) {
|
||||||
|
console.warn(`[auth/token] no session found for state "${state}" — known states: [${[...nativeOidcSessions.keys()].join(', ')}]`);
|
||||||
|
res.status(400).json({ error: "OIDC session not found or expired. Initiate login again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[auth/token] session found, exchanging code with IDP`);
|
||||||
|
|
||||||
|
const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri);
|
||||||
|
console.log(`[auth/token] IDP code exchange OK (access_token present: ${!!tokenSet.access_token}, id_token present: ${!!tokenSet.id_token})`);
|
||||||
|
|
||||||
|
const user = await getUserInfo(tokenSet);
|
||||||
|
console.log(`[auth/token] user resolved (id: ${user.id}, email: ${user.email})`);
|
||||||
|
await syncUser(user);
|
||||||
|
|
||||||
|
// Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>.
|
||||||
|
const backendJwt = signBackendJwt(user);
|
||||||
|
console.log(`[auth/token] backend JWT minted for user ${user.id}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
access_token: backendJwt,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("[auth/token] error:", error);
|
||||||
|
res.status(500).json({ error: `Failed to exchange token: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ services:
|
|||||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||||
OIDC_REDIRECT_URI: "${API_URL}/auth/callback"
|
OIDC_REDIRECT_URI: "${API_URL}/auth/callback"
|
||||||
|
OIDC_IOS_REDIRECT_URI: ${OIDC_IOS_REDIRECT_URI:-timetracker://oauth/callback}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
APP_URL: "${APP_URL}"
|
APP_URL: "${APP_URL}"
|
||||||
|
|||||||
28
ios/TimeTracker/.gitignore
vendored
Normal file
28
ios/TimeTracker/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
*.xcodeproj
|
||||||
|
*.xcworkspace
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM/
|
||||||
|
|
||||||
|
# XcodeGen (regenerate after clean)
|
||||||
|
# project.yml is the source of truth
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
# (not used, we use SPM)
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Build logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
89
ios/TimeTracker/README.md
Normal file
89
ios/TimeTracker/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# TimeTracker iOS App
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. **XcodeGen** - Required to generate the Xcode project
|
||||||
|
```bash
|
||||||
|
# On macOS:
|
||||||
|
brew install xcodegen
|
||||||
|
|
||||||
|
# Or via npm:
|
||||||
|
npm install -g xcodegen
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Xcode** - For building the iOS app (macOS only)
|
||||||
|
|
||||||
|
### Project Generation
|
||||||
|
|
||||||
|
After installing XcodeGen, generate the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios/TimeTracker
|
||||||
|
xcodegen generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create `TimeTracker.xcodeproj` in the `ios/TimeTracker` directory.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Before building, configure the API base URL:
|
||||||
|
|
||||||
|
1. Open `TimeTracker.xcodeproj` in Xcode
|
||||||
|
2. Select the TimeTracker target
|
||||||
|
3. Go to Info.plist
|
||||||
|
4. Add or modify `API_BASE_URL` with your backend URL:
|
||||||
|
- For development: `http://localhost:3001`
|
||||||
|
- For production: Your actual API URL
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
Open the project in Xcode and build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open ios/TimeTracker/TimeTracker.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Then select your target device/simulator and press Cmd+B to build.
|
||||||
|
|
||||||
|
### Authentication Setup
|
||||||
|
|
||||||
|
1. Configure your OIDC provider settings in the backend
|
||||||
|
2. The iOS app uses ASWebAuthenticationSession for OAuth
|
||||||
|
3. The callback URL scheme is `timetracker://oauth/callback`
|
||||||
|
|
||||||
|
### App Groups
|
||||||
|
|
||||||
|
For the widget to work with the main app, configure the App Group:
|
||||||
|
- Identifier: `group.com.timetracker.app`
|
||||||
|
- This is already configured in the project.yml
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
The project uses Swift Package Manager for dependencies:
|
||||||
|
- [SQLite.swift](https://github.com/stephencelis/SQLite.swift) - Local database
|
||||||
|
- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess) - Secure storage
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TimeTracker/
|
||||||
|
├── TimeTrackerApp/ # App entry point
|
||||||
|
├── Core/
|
||||||
|
│ ├── Network/ # API client
|
||||||
|
│ ├── Auth/ # Authentication
|
||||||
|
│ └── Persistence/ # SQLite + sync
|
||||||
|
├── Features/
|
||||||
|
│ ├── Auth/ # Login
|
||||||
|
│ ├── Timer/ # Timer (core feature)
|
||||||
|
│ ├── TimeEntries/ # Time entries CRUD
|
||||||
|
│ ├── Projects/ # Projects CRUD
|
||||||
|
│ ├── Clients/ # Clients CRUD
|
||||||
|
│ └── Dashboard/ # Dashboard
|
||||||
|
├── Models/ # Data models
|
||||||
|
├── Shared/ # Extensions & components
|
||||||
|
└── Resources/ # Assets
|
||||||
|
|
||||||
|
TimeTrackerWidget/ # iOS Widget Extension
|
||||||
|
```
|
||||||
119
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
119
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import KeychainAccess
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthManager: ObservableObject {
|
||||||
|
static let shared = AuthManager()
|
||||||
|
|
||||||
|
@Published private(set) var isAuthenticated = false
|
||||||
|
@Published private(set) var currentUser: User?
|
||||||
|
|
||||||
|
private let keychain: Keychain
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
/// In-memory cache so the token is always available within the current session,
|
||||||
|
/// even if the keychain write fails (e.g. missing entitlement on simulator).
|
||||||
|
private var _accessToken: String?
|
||||||
|
|
||||||
|
/// The backend-issued JWT. Sent as `Authorization: Bearer <token>` on every API call.
|
||||||
|
var accessToken: String? {
|
||||||
|
get {
|
||||||
|
// Return the in-memory value first; fall back to keychain for persistence
|
||||||
|
// across app launches.
|
||||||
|
if let cached = _accessToken { return cached }
|
||||||
|
let stored = try? keychain.get(AppConstants.KeychainKeys.accessToken)
|
||||||
|
_accessToken = stored
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
_accessToken = newValue
|
||||||
|
if let value = newValue {
|
||||||
|
do {
|
||||||
|
try keychain.set(value, key: AppConstants.KeychainKeys.accessToken)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Keychain write failed (token still available in-memory): \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try keychain.remove(AppConstants.KeychainKeys.accessToken)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Keychain remove failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.keychain = Keychain(service: "com.timetracker.app")
|
||||||
|
.accessibility(.whenUnlockedThisDeviceOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthState() async {
|
||||||
|
guard let token = accessToken else {
|
||||||
|
logger.info("checkAuthState — no token in keychain, not authenticated")
|
||||||
|
isAuthenticated = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("checkAuthState — token found (first 20 chars: \(token.prefix(20))…), calling /auth/me")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let user: User = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.me,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
logger.info("checkAuthState — /auth/me OK, user: \(user.id)")
|
||||||
|
currentUser = user
|
||||||
|
isAuthenticated = true
|
||||||
|
} catch {
|
||||||
|
logger.error("checkAuthState — /auth/me failed: \(error.localizedDescription) — clearing auth")
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentUser() async throws -> User {
|
||||||
|
let user: User = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.me,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
currentUser = user
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {
|
||||||
|
// Best-effort server-side logout; the backend JWT is stateless so the
|
||||||
|
// real security comes from clearing the local token.
|
||||||
|
try? await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.logout,
|
||||||
|
method: .post,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAuth() {
|
||||||
|
logger.info("clearAuth — wiping token and user")
|
||||||
|
_accessToken = nil
|
||||||
|
accessToken = nil
|
||||||
|
currentUser = nil
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTokenResponse(_ response: TokenResponse) async {
|
||||||
|
logger.info("handleTokenResponse — storing JWT for user \(response.user.id)")
|
||||||
|
accessToken = response.accessToken
|
||||||
|
currentUser = response.user
|
||||||
|
isAuthenticated = true
|
||||||
|
logger.info("handleTokenResponse — isAuthenticated = true, token stored: \(self.accessToken != nil)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginURL: URL {
|
||||||
|
APIEndpoints.url(for: APIEndpoint.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
var callbackURL: String {
|
||||||
|
AppConfig.authCallbackURL
|
||||||
|
}
|
||||||
|
}
|
||||||
228
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
228
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import Foundation
|
||||||
|
import AuthenticationServices
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthService")
|
||||||
|
|
||||||
|
final class AuthService: NSObject {
|
||||||
|
static let shared = AuthService()
|
||||||
|
|
||||||
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
private var presentationAnchor: ASPresentationAnchor?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(presentationAnchor: ASPresentationAnchor?) async throws {
|
||||||
|
self.presentationAnchor = presentationAnchor
|
||||||
|
|
||||||
|
// Only the redirect_uri is needed — the backend owns PKCE generation.
|
||||||
|
var components = URLComponents(
|
||||||
|
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
components?.queryItems = [
|
||||||
|
URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let authURL = components?.url else {
|
||||||
|
throw AuthError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Starting login — auth URL: \(authURL.absoluteString)")
|
||||||
|
logger.info("Callback URL scheme: \(AppConfig.authCallbackURL)")
|
||||||
|
|
||||||
|
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
|
||||||
|
|
||||||
|
// Use an ephemeral session — we only need the redirect URL back with the
|
||||||
|
// authorization code; no cookies or shared state are needed.
|
||||||
|
let webAuthSession = ASWebAuthenticationSession(
|
||||||
|
url: authURL,
|
||||||
|
callbackURLScheme: callbackScheme
|
||||||
|
) { [weak self] callbackURL, error in
|
||||||
|
if let error = error {
|
||||||
|
let authError: AuthError
|
||||||
|
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||||
|
logger.info("Login cancelled by user")
|
||||||
|
authError = .cancelled
|
||||||
|
} else {
|
||||||
|
logger.error("ASWebAuthenticationSession error: \(error)")
|
||||||
|
authError = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": authError]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let callbackURL = callbackURL else {
|
||||||
|
logger.error("ASWebAuthenticationSession returned nil callbackURL")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.noCallback]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.handleCallback(url: callbackURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
webAuthSession.presentationContextProvider = self
|
||||||
|
// Ephemeral session: no shared cookies or browsing data with Safari.
|
||||||
|
webAuthSession.prefersEphemeralWebBrowserSession = true
|
||||||
|
|
||||||
|
self.authSession = webAuthSession
|
||||||
|
|
||||||
|
let started = webAuthSession.start()
|
||||||
|
if !started {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.failed("Failed to start auth session")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCallback(url: URL) {
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||||
|
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||||
|
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
|
||||||
|
else {
|
||||||
|
logger.error("Callback URL missing code or state: \(url.absoluteString)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.noCallback]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
logger.info("Exchanging code for tokens (state: \(state), redirect_uri: \(AppConfig.authCallbackURL))")
|
||||||
|
let tokenResponse = try await exchangeCodeForTokens(
|
||||||
|
code: code,
|
||||||
|
state: state,
|
||||||
|
redirectUri: AppConfig.authCallbackURL
|
||||||
|
)
|
||||||
|
logger.info("Token exchange succeeded for user: \(tokenResponse.user.id)")
|
||||||
|
|
||||||
|
await AuthManager.shared.handleTokenResponse(tokenResponse)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Token exchange failed: \(error)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.failed(error.localizedDescription)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exchangeCodeForTokens(
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
redirectUri: String
|
||||||
|
) async throws -> TokenResponse {
|
||||||
|
let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
// state is sent so the backend can look up the original PKCE session.
|
||||||
|
// code_verifier is NOT sent — the backend holds it in the in-memory session.
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirectUri
|
||||||
|
]
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw AuthError.failed("Invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyString = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)"
|
||||||
|
logger.debug("POST /auth/token — status \(httpResponse.statusCode), body: \(bodyString)")
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let errorMessage = errorJson["error"] as? String {
|
||||||
|
logger.error("POST /auth/token — server error: \(errorMessage)")
|
||||||
|
throw AuthError.failed(errorMessage)
|
||||||
|
}
|
||||||
|
logger.error("POST /auth/token — unexpected status \(httpResponse.statusCode): \(bodyString)")
|
||||||
|
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode): \(bodyString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AuthService: ASWebAuthenticationPresentationContextProviding {
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
presentationAnchor ?? ASPresentationAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case cancelled
|
||||||
|
case noCallback
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid authentication URL"
|
||||||
|
case .cancelled:
|
||||||
|
return "Login was cancelled"
|
||||||
|
case .noCallback:
|
||||||
|
return "No callback received"
|
||||||
|
case .failed(let message):
|
||||||
|
return "Authentication failed: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let authCallbackReceived = Notification.Name("authCallbackReceived")
|
||||||
|
static let authError = Notification.Name("authError")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokenResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int?
|
||||||
|
let user: User
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case user
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
31
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppConstants {
|
||||||
|
static let appGroupIdentifier = "group.com.timetracker.app"
|
||||||
|
static let authCallbackScheme = "timetracker"
|
||||||
|
static let authCallbackHost = "oauth"
|
||||||
|
|
||||||
|
enum UserDefaultsKeys {
|
||||||
|
static let hasSeenOnboarding = "hasSeenOnboarding"
|
||||||
|
static let cachedTimer = "cachedTimer"
|
||||||
|
static let lastSyncDate = "lastSyncDate"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainKeys {
|
||||||
|
static let accessToken = "accessToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppConfig {
|
||||||
|
static var apiBaseURL: URL {
|
||||||
|
if let url = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String,
|
||||||
|
let baseURL = URL(string: url) {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
return URL(string: "http://localhost:3001")!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var authCallbackURL: String {
|
||||||
|
"\(AppConstants.authCallbackScheme)://\(AppConstants.authCallbackHost)/callback"
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
179
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "APIClient")
|
||||||
|
|
||||||
|
actor APIClient {
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
config.timeoutIntervalForResource = 60
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
endpoint: String,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
body: Encodable? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil,
|
||||||
|
authenticated: Bool = true
|
||||||
|
) async throws -> T {
|
||||||
|
var urlComponents = URLComponents(
|
||||||
|
url: APIEndpoints.url(for: endpoint),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if let queryItems = queryItems, !queryItems.isEmpty {
|
||||||
|
urlComponents?.queryItems = queryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = urlComponents?.url else {
|
||||||
|
throw NetworkError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if authenticated {
|
||||||
|
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||||
|
guard let token = token else {
|
||||||
|
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||||
|
throw NetworkError.unauthorized
|
||||||
|
}
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let body = body {
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||||
|
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||||
|
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||||
|
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.isEmpty {
|
||||||
|
return try decoder.decode(T.self, from: "{}".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch let error as NetworkError {
|
||||||
|
throw error
|
||||||
|
} catch let error as DecodingError {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — decoding error: \(error)")
|
||||||
|
throw NetworkError.decodingError(error)
|
||||||
|
} catch {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||||
|
throw NetworkError.networkError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVoid(
|
||||||
|
endpoint: String,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
body: Encodable? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil,
|
||||||
|
authenticated: Bool = true
|
||||||
|
) async throws {
|
||||||
|
var urlComponents = URLComponents(
|
||||||
|
url: APIEndpoints.url(for: endpoint),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if let queryItems = queryItems, !queryItems.isEmpty {
|
||||||
|
urlComponents?.queryItems = queryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = urlComponents?.url else {
|
||||||
|
throw NetworkError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if authenticated {
|
||||||
|
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||||
|
guard let token = token else {
|
||||||
|
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||||
|
throw NetworkError.unauthorized
|
||||||
|
}
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let body = body {
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||||
|
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||||
|
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||||
|
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||||
|
}
|
||||||
|
} catch let error as NetworkError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||||
|
throw NetworkError.networkError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HTTPMethod: String {
|
||||||
|
case get = "GET"
|
||||||
|
case post = "POST"
|
||||||
|
case put = "PUT"
|
||||||
|
case delete = "DELETE"
|
||||||
|
case patch = "PATCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorResponse: Codable {
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
41
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
41
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIEndpoint {
|
||||||
|
// Auth
|
||||||
|
static let login = "/auth/login"
|
||||||
|
static let callback = "/auth/callback"
|
||||||
|
static let token = "/auth/token"
|
||||||
|
static let logout = "/auth/logout"
|
||||||
|
static let me = "/auth/me"
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
static let clients = "/clients"
|
||||||
|
static func client(id: String) -> String { "/clients/\(id)" }
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
static let projects = "/projects"
|
||||||
|
static func project(id: String) -> String { "/projects/\(id)" }
|
||||||
|
|
||||||
|
// Time Entries
|
||||||
|
static let timeEntries = "/time-entries"
|
||||||
|
static let timeEntriesStatistics = "/time-entries/statistics"
|
||||||
|
static func timeEntry(id: String) -> String { "/time-entries/\(id)" }
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
static let timer = "/timer"
|
||||||
|
static let timerStart = "/timer/start"
|
||||||
|
static let timerStop = "/timer/stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIEndpoints {
|
||||||
|
static func url(for endpoint: String) -> URL {
|
||||||
|
// Use URL(string:relativeTo:) rather than appendingPathComponent so that
|
||||||
|
// leading slashes in endpoint strings are handled correctly and don't
|
||||||
|
// accidentally replace or duplicate the base URL path.
|
||||||
|
let base = AppConfig.apiBaseURL.absoluteString.hasSuffix("/")
|
||||||
|
? AppConfig.apiBaseURL
|
||||||
|
: URL(string: AppConfig.apiBaseURL.absoluteString + "/")!
|
||||||
|
let relative = endpoint.hasPrefix("/") ? String(endpoint.dropFirst()) : endpoint
|
||||||
|
return URL(string: relative, relativeTo: base)!.absoluteURL
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NetworkError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case invalidResponse
|
||||||
|
case httpError(statusCode: Int, message: String?)
|
||||||
|
case decodingError(Error)
|
||||||
|
case networkError(Error)
|
||||||
|
case unauthorized
|
||||||
|
case offline
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid URL"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Invalid response from server"
|
||||||
|
case .httpError(let statusCode, let message):
|
||||||
|
return message ?? "HTTP Error: \(statusCode)"
|
||||||
|
case .decodingError(let error):
|
||||||
|
return "Failed to decode response: \(error.localizedDescription)"
|
||||||
|
case .networkError(let error):
|
||||||
|
return "Network error: \(error.localizedDescription)"
|
||||||
|
case .unauthorized:
|
||||||
|
return "Unauthorized. Please log in again."
|
||||||
|
case .offline:
|
||||||
|
return "No internet connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite
|
||||||
|
|
||||||
|
actor DatabaseService {
|
||||||
|
static let shared = DatabaseService()
|
||||||
|
|
||||||
|
private var db: Connection?
|
||||||
|
|
||||||
|
private let clients = Table("clients")
|
||||||
|
private let projects = Table("projects")
|
||||||
|
private let timeEntries = Table("time_entries")
|
||||||
|
private let pendingSync = Table("pending_sync")
|
||||||
|
|
||||||
|
// Clients columns
|
||||||
|
private let id = SQLite.Expression<String>("id")
|
||||||
|
private let name = SQLite.Expression<String>("name")
|
||||||
|
private let description = SQLite.Expression<String?>("description")
|
||||||
|
private let createdAt = SQLite.Expression<String>("created_at")
|
||||||
|
private let updatedAt = SQLite.Expression<String>("updated_at")
|
||||||
|
|
||||||
|
// Projects columns
|
||||||
|
private let projectClientId = SQLite.Expression<String>("client_id")
|
||||||
|
private let clientName = SQLite.Expression<String>("client_name")
|
||||||
|
private let color = SQLite.Expression<String?>("color")
|
||||||
|
|
||||||
|
// Time entries columns
|
||||||
|
private let startTime = SQLite.Expression<String>("start_time")
|
||||||
|
private let endTime = SQLite.Expression<String>("end_time")
|
||||||
|
private let projectId = SQLite.Expression<String>("project_id")
|
||||||
|
private let projectName = SQLite.Expression<String>("project_name")
|
||||||
|
private let projectColor = SQLite.Expression<String?>("project_color")
|
||||||
|
private let entryDescription = SQLite.Expression<String?>("description")
|
||||||
|
|
||||||
|
// Pending sync columns
|
||||||
|
private let syncId = SQLite.Expression<String>("id")
|
||||||
|
private let syncType = SQLite.Expression<String>("type")
|
||||||
|
private let syncAction = SQLite.Expression<String>("action")
|
||||||
|
private let syncPayload = SQLite.Expression<String>("payload")
|
||||||
|
private let syncCreatedAt = SQLite.Expression<String>("created_at")
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
setupDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDatabase() {
|
||||||
|
do {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let appGroupURL = fileManager.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier
|
||||||
|
)
|
||||||
|
let dbURL = appGroupURL?.appendingPathComponent("timetracker.sqlite3")
|
||||||
|
?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("timetracker.sqlite3")
|
||||||
|
|
||||||
|
db = try Connection(dbURL.path)
|
||||||
|
|
||||||
|
try createTables()
|
||||||
|
} catch {
|
||||||
|
print("Database setup error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTables() throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(clients.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(name)
|
||||||
|
t.column(description)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(projects.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(name)
|
||||||
|
t.column(description)
|
||||||
|
t.column(color)
|
||||||
|
t.column(projectClientId)
|
||||||
|
t.column(clientName)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(timeEntries.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(startTime)
|
||||||
|
t.column(endTime)
|
||||||
|
t.column(entryDescription)
|
||||||
|
t.column(projectId)
|
||||||
|
t.column(projectName)
|
||||||
|
t.column(projectColor)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(pendingSync.create(ifNotExists: true) { t in
|
||||||
|
t.column(syncId, primaryKey: true)
|
||||||
|
t.column(syncType)
|
||||||
|
t.column(syncAction)
|
||||||
|
t.column(syncPayload)
|
||||||
|
t.column(syncCreatedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clients
|
||||||
|
|
||||||
|
func saveClients(_ clientList: [Client]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(clients.delete())
|
||||||
|
|
||||||
|
for client in clientList {
|
||||||
|
try db.run(clients.insert(
|
||||||
|
id <- client.id,
|
||||||
|
name <- client.name,
|
||||||
|
description <- client.description,
|
||||||
|
createdAt <- client.createdAt,
|
||||||
|
updatedAt <- client.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchClients() throws -> [Client] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(clients).map { row in
|
||||||
|
Client(
|
||||||
|
id: row[id],
|
||||||
|
name: row[name],
|
||||||
|
description: row[description],
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Projects
|
||||||
|
|
||||||
|
func saveProjects(_ projectList: [Project]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(projects.delete())
|
||||||
|
|
||||||
|
for project in projectList {
|
||||||
|
try db.run(projects.insert(
|
||||||
|
id <- project.id,
|
||||||
|
name <- project.name,
|
||||||
|
description <- project.description,
|
||||||
|
color <- project.color,
|
||||||
|
projectClientId <- project.clientId,
|
||||||
|
clientName <- project.client.name,
|
||||||
|
createdAt <- project.createdAt,
|
||||||
|
updatedAt <- project.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchProjects() throws -> [Project] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(projects).map { row in
|
||||||
|
let client = ClientReference(id: row[projectClientId], name: row[clientName])
|
||||||
|
let projectRef = ProjectReference(id: row[id], name: row[name], color: row[color], client: client)
|
||||||
|
|
||||||
|
return Project(
|
||||||
|
id: row[id],
|
||||||
|
name: row[name],
|
||||||
|
description: row[description],
|
||||||
|
color: row[color],
|
||||||
|
clientId: row[projectClientId],
|
||||||
|
client: client,
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Entries
|
||||||
|
|
||||||
|
func saveTimeEntries(_ entries: [TimeEntry]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(timeEntries.delete())
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
try db.run(timeEntries.insert(
|
||||||
|
id <- entry.id,
|
||||||
|
startTime <- entry.startTime,
|
||||||
|
endTime <- entry.endTime,
|
||||||
|
entryDescription <- entry.description,
|
||||||
|
projectId <- entry.projectId,
|
||||||
|
projectName <- entry.project.name,
|
||||||
|
projectColor <- entry.project.color,
|
||||||
|
createdAt <- entry.createdAt,
|
||||||
|
updatedAt <- entry.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTimeEntries() throws -> [TimeEntry] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(timeEntries).map { row in
|
||||||
|
let client = ClientReference(id: "", name: "")
|
||||||
|
let projectRef = ProjectReference(
|
||||||
|
id: row[projectId],
|
||||||
|
name: row[projectName],
|
||||||
|
color: row[projectColor],
|
||||||
|
client: client
|
||||||
|
)
|
||||||
|
|
||||||
|
return TimeEntry(
|
||||||
|
id: row[id],
|
||||||
|
startTime: row[startTime],
|
||||||
|
endTime: row[endTime],
|
||||||
|
description: row[entryDescription],
|
||||||
|
projectId: row[projectId],
|
||||||
|
project: projectRef,
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Sync
|
||||||
|
|
||||||
|
func addPendingSync(type: String, action: String, payload: String) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(pendingSync.insert(
|
||||||
|
syncId <- UUID().uuidString,
|
||||||
|
syncType <- type,
|
||||||
|
syncAction <- action,
|
||||||
|
syncPayload <- payload,
|
||||||
|
syncCreatedAt <- ISO8601DateFormatter().string(from: Date())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPendingSync() throws -> [(id: String, type: String, action: String, payload: String)] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(pendingSync).map { row in
|
||||||
|
(row[syncId], row[syncType], row[syncAction], row[syncPayload])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePendingSync(id: String) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(pendingSync.filter(syncId == id).delete())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer Cache
|
||||||
|
|
||||||
|
func cacheTimer(_ timer: OngoingTimer?) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
if let timer = timer {
|
||||||
|
let data = try encoder.encode(timer)
|
||||||
|
UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.set(data, forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
} else {
|
||||||
|
UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.removeObject(forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedTimer() throws -> OngoingTimer? {
|
||||||
|
let data = UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.data(forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
|
||||||
|
guard let data = data else { return nil }
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try decoder.decode(OngoingTimer.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SyncManager: ObservableObject {
|
||||||
|
static let shared = SyncManager()
|
||||||
|
|
||||||
|
@Published private(set) var isOnline = true
|
||||||
|
@Published private(set) var isSyncing = false
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "com.timetracker.networkmonitor")
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
startNetworkMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNetworkMonitoring() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.isOnline = path.status == .satisfied
|
||||||
|
if path.status == .satisfied {
|
||||||
|
Task { await self?.syncPendingChanges() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncPendingChanges() async {
|
||||||
|
guard isOnline, !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let pending = try await database.fetchPendingSync()
|
||||||
|
|
||||||
|
for item in pending {
|
||||||
|
do {
|
||||||
|
try await processPendingItem(item)
|
||||||
|
try await database.removePendingSync(id: item.id)
|
||||||
|
} catch {
|
||||||
|
print("Failed to sync item \(item.id): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch pending sync: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processPendingItem(_ item: (id: String, type: String, action: String, payload: String)) async throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
switch item.type {
|
||||||
|
case "timeEntry":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateTimeEntryInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateTimeEntryInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "client":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateClientInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateClientInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "project":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateProjectInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateProjectInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
89
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
|
Text("TimeTracker")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Track your time spent on projects")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
login()
|
||||||
|
} label: {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("Sign In")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(isLoading)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .authCallbackReceived)) { notification in
|
||||||
|
handleAuthCallback(notification.userInfo)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .authError)) { notification in
|
||||||
|
if let authError = notification.userInfo?["error"] as? AuthError {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = authError.errorDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func login() {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let authService = AuthService.shared
|
||||||
|
do {
|
||||||
|
try await authService.login(presentationAnchor: nil)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) {
|
||||||
|
// AuthManager.handleTokenResponse() already set isAuthenticated = true
|
||||||
|
// and populated currentUser during the token exchange in AuthService.
|
||||||
|
// No further network call is needed here.
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
123
ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift
Normal file
123
ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ClientsView: View {
|
||||||
|
@StateObject private var viewModel = ClientsViewModel()
|
||||||
|
@State private var showAddClient = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.clients.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.clients.isEmpty {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadClients() }
|
||||||
|
}
|
||||||
|
} else if viewModel.clients.isEmpty {
|
||||||
|
EmptyView(
|
||||||
|
icon: "person.2",
|
||||||
|
title: "No Clients",
|
||||||
|
message: "Create a client to organize your projects."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clientsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Clients")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showAddClient = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadClients()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddClient) {
|
||||||
|
ClientFormView(onSave: { name, description in
|
||||||
|
Task {
|
||||||
|
await viewModel.createClient(name: name, description: description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clientsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.clients) { client in
|
||||||
|
ClientRow(client: client)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
Task {
|
||||||
|
for index in indexSet {
|
||||||
|
await viewModel.deleteClient(viewModel.clients[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientRow: View {
|
||||||
|
let client: Client
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(client.name)
|
||||||
|
.font(.headline)
|
||||||
|
if let description = client.description {
|
||||||
|
Text(description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientFormView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let onSave: (String, String?) -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var description = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Client name", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Description (Optional)") {
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Client")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
onSave(name, description.isEmpty ? nil : description)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ClientsViewModel: ObservableObject {
|
||||||
|
@Published var clients: [Client] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
func loadClients() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
clients = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.saveClients(clients)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Load from cache
|
||||||
|
clients = (try? await database.fetchClients()) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClient(name: String, description: String?) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateClientInput(name: name, description: description)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadClients()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateClient(id: String, name: String, description: String?) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = UpdateClientInput(name: name, description: description)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadClients()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteClient(_ client: Client) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: client.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
clients.removeAll { $0.id == client.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@StateObject private var viewModel = DashboardViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Active Timer Card
|
||||||
|
timerCard
|
||||||
|
|
||||||
|
// Weekly Stats
|
||||||
|
if let stats = viewModel.statistics {
|
||||||
|
statsSection(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Entries
|
||||||
|
recentEntriesSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Dashboard")
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let timer = viewModel.activeTimer {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Timer Running")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(viewModel.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 32, weight: .medium, design: .monospaced))
|
||||||
|
|
||||||
|
if let project = timer.project {
|
||||||
|
ProjectColorBadge(color: project.color, name: project.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("No Active Timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Start tracking to see your time")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statsSection(_ stats: TimeStatistics) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("This Week")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
StatCard(
|
||||||
|
title: "Hours Tracked",
|
||||||
|
value: TimeInterval(stats.totalSeconds).formattedHours,
|
||||||
|
icon: "clock.fill",
|
||||||
|
color: .blue
|
||||||
|
)
|
||||||
|
|
||||||
|
StatCard(
|
||||||
|
title: "Entries",
|
||||||
|
value: "\(stats.entryCount)",
|
||||||
|
icon: "list.bullet",
|
||||||
|
color: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stats.byProject.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("By Project")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(stats.byProject.prefix(5)) { projectStat in
|
||||||
|
HStack {
|
||||||
|
if let color = projectStat.projectColor {
|
||||||
|
ProjectColorDot(color: color)
|
||||||
|
}
|
||||||
|
Text(projectStat.projectName)
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text(TimeInterval(projectStat.totalSeconds).formattedHours)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentEntriesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Recent Entries")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if viewModel.recentEntries.isEmpty {
|
||||||
|
Text("No entries yet")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.recentEntries) { entry in
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: entry.project.color, size: 10)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.project.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(formatDate(entry.startTime))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(entry.duration.formattedHours)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ isoString: String) -> String {
|
||||||
|
guard let date = Date.fromISO8601(isoString) else { return "" }
|
||||||
|
return date.formattedDateTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DashboardViewModel: ObservableObject {
|
||||||
|
@Published var activeTimer: OngoingTimer?
|
||||||
|
@Published var statistics: TimeStatistics?
|
||||||
|
@Published var recentEntries: [TimeEntry] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var elapsedTime: TimeInterval = 0
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
private var timerTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
startElapsedTimeUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch active timer
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get statistics for this week
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = Date()
|
||||||
|
let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
||||||
|
let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)!
|
||||||
|
|
||||||
|
let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek)
|
||||||
|
statistics = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntriesStatistics,
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "startDate", value: startOfWeek.iso8601FullDate),
|
||||||
|
URLQueryItem(name: "endDate", value: endOfWeek.iso8601FullDate)
|
||||||
|
],
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch recent entries
|
||||||
|
let entriesResponse: TimeEntryListResponse = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "limit", value: "5")
|
||||||
|
],
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
recentEntries = entriesResponse.entries
|
||||||
|
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Try to load cached data
|
||||||
|
if let cachedTimer = try? await database.getCachedTimer() {
|
||||||
|
activeTimer = cachedTimer
|
||||||
|
elapsedTime = cachedTimer.elapsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startElapsedTimeUpdater() {
|
||||||
|
timerTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
|
||||||
|
guard let self = self, self.activeTimer != nil else { continue }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift
Normal file
167
ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProjectsView: View {
|
||||||
|
@StateObject private var viewModel = ProjectsViewModel()
|
||||||
|
@State private var showAddProject = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.projects.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.projects.isEmpty {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadData() }
|
||||||
|
}
|
||||||
|
} else if viewModel.projects.isEmpty {
|
||||||
|
EmptyView(
|
||||||
|
icon: "folder",
|
||||||
|
title: "No Projects",
|
||||||
|
message: "Create a project to start tracking time."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
projectsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Projects")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showAddProject = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddProject) {
|
||||||
|
ProjectFormView(
|
||||||
|
clients: viewModel.clients,
|
||||||
|
onSave: { name, description, color, clientId in
|
||||||
|
Task {
|
||||||
|
await viewModel.createProject(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
clientId: clientId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var projectsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.projects) { project in
|
||||||
|
ProjectRow(project: project)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
Task {
|
||||||
|
for index in indexSet {
|
||||||
|
await viewModel.deleteProject(viewModel.projects[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectRow: View {
|
||||||
|
let project: Project
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color, size: 16)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(project.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(project.client.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectFormView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let clients: [Client]
|
||||||
|
let onSave: (String, String?, String?, String) -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var selectedColor: String = "#3B82F6"
|
||||||
|
@State private var selectedClient: Client?
|
||||||
|
|
||||||
|
private let colors = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
|
||||||
|
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Project name", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Description (Optional)") {
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Color") {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
|
||||||
|
ForEach(colors, id: \.self) { color in
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: color))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.primary, lineWidth: selectedColor == color ? 3 : 0)
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedColor = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Client") {
|
||||||
|
Picker("Client", selection: $selectedClient) {
|
||||||
|
Text("Select Client").tag(nil as Client?)
|
||||||
|
ForEach(clients) { client in
|
||||||
|
Text(client.name)
|
||||||
|
.tag(client as Client?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
guard let client = selectedClient else { return }
|
||||||
|
onSave(name, description.isEmpty ? nil : description, selectedColor, client.id)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || selectedClient == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ProjectsViewModel: ObservableObject {
|
||||||
|
@Published var projects: [Project] = []
|
||||||
|
@Published var clients: [Client] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
clients = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.saveProjects(projects)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Load from cache
|
||||||
|
projects = (try? await database.fetchProjects()) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProject(name: String, description: String?, color: String?, clientId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
clientId: clientId
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProject(id: String, name: String, description: String?, color: String?, clientId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = UpdateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
clientId: clientId
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProject(_ project: Project) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: project.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
projects.removeAll { $0.id == project.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimeEntriesView: View {
|
||||||
|
@StateObject private var viewModel = TimeEntriesViewModel()
|
||||||
|
@State private var showAddEntry = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.entries.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.entries.isEmpty {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
}
|
||||||
|
} else if viewModel.entries.isEmpty {
|
||||||
|
EmptyView(
|
||||||
|
icon: "clock",
|
||||||
|
title: "No Time Entries",
|
||||||
|
message: "Start tracking your time to see entries here."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
entriesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Time Entries")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showAddEntry = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadEntries()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddEntry) {
|
||||||
|
TimeEntryFormView(onSave: {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entriesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.entries) { entry in
|
||||||
|
TimeEntryRow(entry: entry)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
Task {
|
||||||
|
for index in indexSet {
|
||||||
|
await viewModel.deleteEntry(viewModel.entries[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadEntries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeEntryRow: View {
|
||||||
|
let entry: TimeEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: entry.project.color)
|
||||||
|
Text(entry.project.name)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text(entry.duration.formattedHours)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(formatDateRange(start: entry.startTime, end: entry.endTime))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(entry.project.client.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let description = entry.description {
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDateRange(start: String, end: String) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d, HH:mm"
|
||||||
|
|
||||||
|
guard let startDate = Date.fromISO8601(start),
|
||||||
|
let endDate = Date.fromISO8601(end) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimeEntriesViewModel: ObservableObject {
|
||||||
|
@Published var entries: [TimeEntry] = []
|
||||||
|
@Published var pagination: Pagination?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var filters = TimeEntryFilters()
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
func loadEntries() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
var queryItems: [URLQueryItem] = []
|
||||||
|
|
||||||
|
if let startDate = filters.startDate {
|
||||||
|
queryItems.append(URLQueryItem(name: "startDate", value: startDate))
|
||||||
|
}
|
||||||
|
if let endDate = filters.endDate {
|
||||||
|
queryItems.append(URLQueryItem(name: "endDate", value: endDate))
|
||||||
|
}
|
||||||
|
if let projectId = filters.projectId {
|
||||||
|
queryItems.append(URLQueryItem(name: "projectId", value: projectId))
|
||||||
|
}
|
||||||
|
if let page = filters.page {
|
||||||
|
queryItems.append(URLQueryItem(name: "page", value: String(page)))
|
||||||
|
}
|
||||||
|
if let limit = filters.limit {
|
||||||
|
queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: TimeEntryListResponse = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
queryItems: queryItems.isEmpty ? nil : queryItems,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = response.entries
|
||||||
|
pagination = response.pagination
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextPage() async {
|
||||||
|
guard let pagination = pagination,
|
||||||
|
pagination.page < pagination.totalPages else { return }
|
||||||
|
|
||||||
|
filters.page = pagination.page + 1
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousPage() async {
|
||||||
|
guard let pagination = pagination,
|
||||||
|
pagination.page > 1 else { return }
|
||||||
|
|
||||||
|
filters.page = pagination.page - 1
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(_ entry: TimeEntry) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: entry.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.removeAll { $0.id == entry.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimeEntryFormView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let entry: TimeEntry?
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
@State private var startDate = Date()
|
||||||
|
@State private var startTime = Date()
|
||||||
|
@State private var endDate = Date()
|
||||||
|
@State private var endTime = Date()
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var selectedProject: Project?
|
||||||
|
@State private var projects: [Project] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) {
|
||||||
|
self.entry = entry
|
||||||
|
self.onSave = onSave
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Project") {
|
||||||
|
Picker("Project", selection: $selectedProject) {
|
||||||
|
Text("Select Project").tag(nil as Project?)
|
||||||
|
ForEach(projects) { project in
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color)
|
||||||
|
Text(project.name)
|
||||||
|
}
|
||||||
|
.tag(project as Project?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Start Time") {
|
||||||
|
DatePicker("Date", selection: $startDate, displayedComponents: .date)
|
||||||
|
DatePicker("Time", selection: $startTime, displayedComponents: .hourAndMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("End Time") {
|
||||||
|
DatePicker("Date", selection: $endDate, displayedComponents: .date)
|
||||||
|
DatePicker("Time", selection: $endTime, displayedComponents: .hourAndMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Description (Optional)") {
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(entry == nil ? "New Entry" : "Edit Entry")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
.disabled(selectedProject == nil || isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadProjects()
|
||||||
|
if let entry = entry {
|
||||||
|
await loadEntry(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProjects() async {
|
||||||
|
do {
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadEntry(_ entry: TimeEntry) async {
|
||||||
|
let startFormatter = DateFormatter()
|
||||||
|
startFormatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
let timeFormatter = DateFormatter()
|
||||||
|
timeFormatter.dateFormat = "HH:mm"
|
||||||
|
|
||||||
|
if let startDateObj = Date.fromISO8601(entry.startTime) {
|
||||||
|
startDate = startDateObj
|
||||||
|
startTime = startDateObj
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endDateObj = Date.fromISO8601(entry.endTime) {
|
||||||
|
endDate = endDateObj
|
||||||
|
endTime = endDateObj
|
||||||
|
}
|
||||||
|
|
||||||
|
description = entry.description ?? ""
|
||||||
|
selectedProject = projects.first { $0.id == entry.projectId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let project = selectedProject else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
|
||||||
|
minute: calendar.component(.minute, from: startTime),
|
||||||
|
second: 0,
|
||||||
|
of: startDate) ?? startDate
|
||||||
|
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
|
||||||
|
minute: calendar.component(.minute, from: endTime),
|
||||||
|
second: 0,
|
||||||
|
of: endDate) ?? endDate
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if let existingEntry = entry {
|
||||||
|
let input = UpdateTimeEntryInput(
|
||||||
|
startTime: startDateTime.iso8601String,
|
||||||
|
endTime: endDateTime.iso8601String,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
projectId: project.id
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let input = CreateTimeEntryInput(
|
||||||
|
startTime: startDateTime,
|
||||||
|
endTime: endDateTime,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
projectId: project.id
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
dismiss()
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
let errorMessage = error.localizedDescription
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
self.error = errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
180
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimerView: View {
|
||||||
|
@StateObject private var viewModel = TimerViewModel()
|
||||||
|
@State private var showProjectPicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.activeTimer == nil {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.activeTimer == nil {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadData() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timerContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Timer")
|
||||||
|
.task {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showProjectPicker) {
|
||||||
|
ProjectPickerSheet(
|
||||||
|
projects: viewModel.projects,
|
||||||
|
selectedProject: viewModel.selectedProject
|
||||||
|
) { project in
|
||||||
|
Task {
|
||||||
|
await viewModel.updateProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerContent: some View {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Timer Display
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(viewModel.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 64, weight: .light, design: .monospaced))
|
||||||
|
.foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary)
|
||||||
|
|
||||||
|
if let project = viewModel.selectedProject {
|
||||||
|
ProjectColorBadge(
|
||||||
|
color: project.color,
|
||||||
|
name: project.name
|
||||||
|
)
|
||||||
|
} else if let timerProject = viewModel.activeTimer?.project {
|
||||||
|
ProjectColorBadge(
|
||||||
|
color: timerProject.color,
|
||||||
|
name: timerProject.name
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("No project selected")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if viewModel.activeTimer == nil {
|
||||||
|
Button {
|
||||||
|
showProjectPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
Text(viewModel.selectedProject?.name ?? "Select Project")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.startTimer() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
Text("Start Timer")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
showProjectPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
Text("Change Project")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.stopTimer() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
Text("Stop Timer")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectPickerSheet: View {
|
||||||
|
let projects: [Project]
|
||||||
|
let selectedProject: Project?
|
||||||
|
let onSelect: (Project?) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Button {
|
||||||
|
onSelect(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("No Project")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if selectedProject == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(projects) { project in
|
||||||
|
Button {
|
||||||
|
onSelect(project)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color)
|
||||||
|
Text(project.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(project.client.name)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if selectedProject?.id == project.id {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
159
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimerViewModel: ObservableObject {
|
||||||
|
@Published var activeTimer: OngoingTimer?
|
||||||
|
@Published var projects: [Project] = []
|
||||||
|
@Published var selectedProject: Project?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var elapsedTime: TimeInterval = 0
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
private var timerTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
startElapsedTimeUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch active timer
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache timer for widget
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
|
||||||
|
// Fetch projects
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set selected project if timer has one
|
||||||
|
if let timerProject = activeTimer?.project {
|
||||||
|
selectedProject = projects.first { $0.id == timerProject.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate elapsed time
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Try to load cached data
|
||||||
|
if let cachedTimer = try? await database.getCachedTimer() {
|
||||||
|
activeTimer = cachedTimer
|
||||||
|
elapsedTime = cachedTimer.elapsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTimer() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = StartTimerInput(projectId: selectedProject?.id)
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timerStart,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTimer() async {
|
||||||
|
guard let timer = activeTimer else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
let projectId = selectedProject?.id ?? timer.projectId ?? ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = StopTimerInput(projectId: projectId)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timerStop,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
activeTimer = nil
|
||||||
|
selectedProject = nil
|
||||||
|
elapsedTime = 0
|
||||||
|
|
||||||
|
try await database.cacheTimer(nil)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProject(_ project: Project?) async {
|
||||||
|
selectedProject = project
|
||||||
|
|
||||||
|
guard let timer = activeTimer else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let projectId = project?.id else { return }
|
||||||
|
|
||||||
|
let input = UpdateTimerInput(projectId: projectId)
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startElapsedTimeUpdater() {
|
||||||
|
timerTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
|
||||||
|
guard let self = self, self.activeTimer != nil else { continue }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_BASE_URL</key>
|
||||||
|
<string>http://localhost:3001</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.timetracker.app</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>timetracker</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchBackground</string>
|
||||||
|
</dict>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
19
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
19
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Client: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateClientInput: Codable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateClientInput: Codable {
|
||||||
|
let name: String?
|
||||||
|
let description: String?
|
||||||
|
}
|
||||||
41
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
41
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct OngoingTimer: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let startTime: String
|
||||||
|
let projectId: String?
|
||||||
|
let project: ProjectReference?
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
var elapsedTime: TimeInterval {
|
||||||
|
guard let start = ISO8601DateFormatter().date(from: startTime) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Date().timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StartTimerInput: Codable {
|
||||||
|
let projectId: String?
|
||||||
|
|
||||||
|
init(projectId: String? = nil) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTimerInput: Codable {
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(projectId: String) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StopTimerInput: Codable {
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(projectId: String) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
31
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Project: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String
|
||||||
|
let client: ClientReference
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientReference: Codable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateProjectInput: Codable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateProjectInput: Codable {
|
||||||
|
let name: String?
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String?
|
||||||
|
}
|
||||||
91
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
91
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimeEntry: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let startTime: String
|
||||||
|
let endTime: String
|
||||||
|
let description: String?
|
||||||
|
let projectId: String
|
||||||
|
let project: ProjectReference
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
guard let start = ISO8601DateFormatter().date(from: startTime),
|
||||||
|
let end = ISO8601DateFormatter().date(from: endTime) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectReference: Codable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let color: String?
|
||||||
|
let client: ClientReference
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeEntryListResponse: Codable {
|
||||||
|
let entries: [TimeEntry]
|
||||||
|
let pagination: Pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pagination: Codable, Equatable {
|
||||||
|
let page: Int
|
||||||
|
let limit: Int
|
||||||
|
let total: Int
|
||||||
|
let totalPages: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeEntryFilters: Codable {
|
||||||
|
var startDate: String?
|
||||||
|
var endDate: String?
|
||||||
|
var projectId: String?
|
||||||
|
var clientId: String?
|
||||||
|
var page: Int?
|
||||||
|
var limit: Int?
|
||||||
|
|
||||||
|
init(
|
||||||
|
startDate: Date? = nil,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
projectId: String? = nil,
|
||||||
|
clientId: String? = nil,
|
||||||
|
page: Int = 1,
|
||||||
|
limit: Int = 20
|
||||||
|
) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
|
||||||
|
self.startDate = startDate.map { formatter.string(from: $0) }
|
||||||
|
self.endDate = endDate.map { formatter.string(from: $0) }
|
||||||
|
self.projectId = projectId
|
||||||
|
self.clientId = clientId
|
||||||
|
self.page = page
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateTimeEntryInput: Codable {
|
||||||
|
let startTime: String
|
||||||
|
let endTime: String
|
||||||
|
let description: String?
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(startTime: Date, endTime: Date, description: String? = nil, projectId: String) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
|
||||||
|
self.startTime = formatter.string(from: startTime)
|
||||||
|
self.endTime = formatter.string(from: endTime)
|
||||||
|
self.description = description
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTimeEntryInput: Codable {
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
let description: String?
|
||||||
|
let projectId: String?
|
||||||
|
}
|
||||||
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimeStatistics: Codable, Equatable {
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
let byProject: [ProjectStatistics]
|
||||||
|
let byClient: [ClientStatistics]
|
||||||
|
let filters: StatisticsFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectStatistics: Codable, Identifiable, Equatable {
|
||||||
|
let projectId: String
|
||||||
|
let projectName: String
|
||||||
|
let projectColor: String?
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
|
||||||
|
var id: String { projectId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientStatistics: Codable, Identifiable, Equatable {
|
||||||
|
let clientId: String
|
||||||
|
let clientName: String
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
|
||||||
|
var id: String { clientId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatisticsFilters: Codable, Equatable {
|
||||||
|
let startDate: String?
|
||||||
|
let endDate: String?
|
||||||
|
let projectId: String?
|
||||||
|
let clientId: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatisticsFiltersInput: Codable {
|
||||||
|
let startDate: String?
|
||||||
|
let endDate: String?
|
||||||
|
let projectId: String?
|
||||||
|
let clientId: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
startDate: Date? = nil,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
projectId: String? = nil,
|
||||||
|
clientId: String? = nil
|
||||||
|
) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
|
||||||
|
self.startDate = startDate.map { formatter.string(from: $0) }
|
||||||
|
self.endDate = endDate.map { formatter.string(from: $0) }
|
||||||
|
self.projectId = projectId
|
||||||
|
self.clientId = clientId
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Codable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let fullName: String?
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserResponse: Codable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let fullName: String?
|
||||||
|
let email: String
|
||||||
|
|
||||||
|
func toUser() -> User {
|
||||||
|
User(id: id, username: username, fullName: fullName, email: email)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.984",
|
||||||
|
"green" : "0.584",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoadingView: View {
|
||||||
|
var message: String = "Loading..."
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorView: View {
|
||||||
|
let message: String
|
||||||
|
var retryAction: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let retryAction = retryAction {
|
||||||
|
Button("Retry", action: retryAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
var message: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let message = message {
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProjectColorDot: View {
|
||||||
|
let color: String?
|
||||||
|
var size: CGFloat = 12
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(colorValue)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var colorValue: Color {
|
||||||
|
if let hex = color {
|
||||||
|
return Color(hex: hex)
|
||||||
|
}
|
||||||
|
return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectColorBadge: View {
|
||||||
|
let color: String?
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProjectColorDot(color: color, size: 10)
|
||||||
|
Text(name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
48
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatCard: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let icon: String
|
||||||
|
var color: Color = .accentColor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
var formattedDuration: String {
|
||||||
|
let hours = Int(self) / 3600
|
||||||
|
let minutes = (Int(self) % 3600) / 60
|
||||||
|
let seconds = Int(self) % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedHours: String {
|
||||||
|
let hours = self / 3600
|
||||||
|
return String(format: "%.1fh", hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
|
|
||||||
|
let a, r, g, b: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3: // RGB (12-bit)
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
|
case 6: // RGB (24-bit)
|
||||||
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
case 8: // ARGB (32-bit)
|
||||||
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(a, r, g, b) = (255, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultProjectColors: [Color] = [
|
||||||
|
Color(hex: "EF4444"), // Red
|
||||||
|
Color(hex: "F97316"), // Orange
|
||||||
|
Color(hex: "EAB308"), // Yellow
|
||||||
|
Color(hex: "22C55E"), // Green
|
||||||
|
Color(hex: "14B8A6"), // Teal
|
||||||
|
Color(hex: "06B6D4"), // Cyan
|
||||||
|
Color(hex: "3B82F6"), // Blue
|
||||||
|
Color(hex: "6366F1"), // Indigo
|
||||||
|
Color(hex: "A855F7"), // Purple
|
||||||
|
Color(hex: "EC4899"), // Pink
|
||||||
|
]
|
||||||
|
|
||||||
|
static func projectColor(for index: Int) -> Color {
|
||||||
|
defaultProjectColors[index % defaultProjectColors.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.day = 1
|
||||||
|
components.second = -1
|
||||||
|
return Calendar.current.date(byAdding: components, to: startOfDay) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var startOfWeek: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self)
|
||||||
|
return calendar.date(from: components) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfWeek: Date {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.day = 7
|
||||||
|
components.second = -1
|
||||||
|
return Calendar.current.date(byAdding: components, to: startOfWeek) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedDateTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var iso8601String: String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var iso8601FullDate: String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromISO8601(_ string: String) -> Date? {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func hideKeyboard() {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
|
||||||
|
if condition {
|
||||||
|
transform(self)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FormFieldStyle: ViewModifier {
|
||||||
|
var isEditing: Bool = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isEditing ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func formFieldStyle(isEditing: Bool = false) -> some View {
|
||||||
|
modifier(FormFieldStyle(isEditing: isEditing))
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.timetracker.app</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TimeTrackerApp: App {
|
||||||
|
@StateObject private var authManager = AuthManager.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environmentObject(authManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if authManager.isAuthenticated {
|
||||||
|
MainTabView()
|
||||||
|
} else {
|
||||||
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await authManager.checkAuthState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabView: View {
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "chart.bar")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
TimerView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Timer", systemImage: "timer")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
TimeEntriesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Entries", systemImage: "clock")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
ProjectsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Projects", systemImage: "folder")
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
|
||||||
|
ClientsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Clients", systemImage: "person.2")
|
||||||
|
}
|
||||||
|
.tag(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ios/TimeTracker/TimeTrackerWidget/Info.plist
Normal file
29
ios/TimeTracker/TimeTrackerWidget/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Timer</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.timetracker.app</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
194
ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift
Normal file
194
ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimerEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let timer: WidgetTimer?
|
||||||
|
let projectName: String?
|
||||||
|
let projectColor: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WidgetTimer: Codable {
|
||||||
|
let id: String
|
||||||
|
let startTime: String
|
||||||
|
let projectId: String?
|
||||||
|
|
||||||
|
var elapsedTime: TimeInterval {
|
||||||
|
guard let start = ISO8601DateFormatter().date(from: startTime) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Date().timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider: TimelineProvider {
|
||||||
|
private let appGroupIdentifier = "group.com.timetracker.app"
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> TimerEntry {
|
||||||
|
TimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
timer: nil,
|
||||||
|
projectName: nil,
|
||||||
|
projectColor: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (TimerEntry) -> Void) {
|
||||||
|
let entry = loadTimerEntry()
|
||||||
|
completion(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<TimerEntry>) -> Void) {
|
||||||
|
let entry = loadTimerEntry()
|
||||||
|
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
||||||
|
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||||
|
|
||||||
|
completion(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadTimerEntry() -> TimerEntry {
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||||
|
let data = userDefaults.data(forKey: "cachedTimer") else {
|
||||||
|
return TimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
timer: nil,
|
||||||
|
projectName: nil,
|
||||||
|
projectColor: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let timer = try JSONDecoder().decode(WidgetTimer.self, from: data)
|
||||||
|
return TimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
timer: timer,
|
||||||
|
projectName: timer.projectId,
|
||||||
|
projectColor: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return TimerEntry(
|
||||||
|
date: Date(),
|
||||||
|
timer: nil,
|
||||||
|
projectName: nil,
|
||||||
|
projectColor: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimerWidgetEntryView: View {
|
||||||
|
var entry: Provider.Entry
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
smallWidget
|
||||||
|
case .systemMedium:
|
||||||
|
mediumWidget
|
||||||
|
default:
|
||||||
|
smallWidget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var smallWidget: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let timer = entry.timer {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
Text(timer.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 24, weight: .medium, design: .monospaced))
|
||||||
|
|
||||||
|
if let projectId = entry.projectName {
|
||||||
|
Text(projectId)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.containerBackground(for: .widget) {
|
||||||
|
Color(.systemBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediumWidget: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if let timer = entry.timer {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
Text(timer.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 28, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let projectId = entry.projectName {
|
||||||
|
Text(projectId)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
Text("Tap to open")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No active timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.containerBackground(for: .widget) {
|
||||||
|
Color(.systemBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
var formattedDuration: String {
|
||||||
|
let hours = Int(self) / 3600
|
||||||
|
let minutes = (Int(self) % 3600) / 60
|
||||||
|
let seconds = Int(self) % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeTrackerWidget: Widget {
|
||||||
|
let kind: String = "TimeTrackerWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
||||||
|
TimerWidgetEntryView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Timer")
|
||||||
|
.description("Shows your active timer.")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TimeTrackerWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
TimeTrackerWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
94
ios/TimeTracker/project.yml
Normal file
94
ios/TimeTracker/project.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
name: TimeTracker
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.timetracker
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
xcodeVersion: "15.0"
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
SQLite:
|
||||||
|
url: https://github.com/stephencelis/SQLite.swift
|
||||||
|
version: 0.15.3
|
||||||
|
KeychainAccess:
|
||||||
|
url: https://github.com/kishikawakatsumi/KeychainAccess
|
||||||
|
version: 4.2.2
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
CODE_SIGN_IDENTITY: ""
|
||||||
|
CODE_SIGNING_REQUIRED: "NO"
|
||||||
|
CODE_SIGNING_ALLOWED: "NO"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
TimeTracker:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: TimeTracker
|
||||||
|
excludes:
|
||||||
|
- "**/.DS_Store"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
INFOPLIST_FILE: TimeTracker/Info.plist
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.timetracker.app
|
||||||
|
MARKETING_VERSION: "1.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: "YES"
|
||||||
|
dependencies:
|
||||||
|
- target: TimeTrackerWidget
|
||||||
|
embed: true
|
||||||
|
- package: SQLite
|
||||||
|
product: SQLite
|
||||||
|
- package: KeychainAccess
|
||||||
|
entitlements:
|
||||||
|
path: TimeTracker/TimeTracker.entitlements
|
||||||
|
properties:
|
||||||
|
com.apple.security.application-groups:
|
||||||
|
- group.com.timetracker.app
|
||||||
|
|
||||||
|
TimeTrackerWidget:
|
||||||
|
type: app-extension
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: TimeTrackerWidget
|
||||||
|
excludes:
|
||||||
|
- "**/.DS_Store"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
INFOPLIST_FILE: TimeTrackerWidget/Info.plist
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.timetracker.app.widget
|
||||||
|
MARKETING_VERSION: "1.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
SKIP_INSTALL: "YES"
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME: WidgetBackground
|
||||||
|
CODE_SIGN_ENTITLEMENTS: TimeTrackerWidget/TimeTrackerWidget.entitlements
|
||||||
|
dependencies:
|
||||||
|
- package: SQLite
|
||||||
|
product: SQLite
|
||||||
|
entitlements:
|
||||||
|
path: TimeTrackerWidget/TimeTrackerWidget.entitlements
|
||||||
|
properties:
|
||||||
|
com.apple.security.application-groups:
|
||||||
|
- group.com.timetracker.app
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
TimeTracker:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
TimeTracker: all
|
||||||
|
TimeTrackerWidget: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
@@ -33,8 +33,13 @@ IMPORTANT NOTES:
|
|||||||
- Set postgresql.url in values.yaml to point to your existing database.
|
- Set postgresql.url in values.yaml to point to your existing database.
|
||||||
- Make sure to change the OIDC configuration in values.yaml
|
- Make sure to change the OIDC configuration in values.yaml
|
||||||
- Change the SESSION_SECRET from the default value for production
|
- Change the SESSION_SECRET from the default value for production
|
||||||
|
- Set backend.jwt.secret to a dedicated secret in production (falls back to SESSION_SECRET if empty)
|
||||||
- Configure ingress host and TLS settings for your environment
|
- Configure ingress host and TLS settings for your environment
|
||||||
|
|
||||||
OIDC Configuration Required:
|
OIDC Configuration Required:
|
||||||
issuerUrl: {{ .Values.backend.oidc.issuerUrl | default "NOT SET - REQUIRED" }}
|
issuerUrl: {{ .Values.backend.oidc.issuerUrl | default "NOT SET - REQUIRED" }}
|
||||||
clientId: {{ .Values.backend.oidc.clientId | default "NOT SET - REQUIRED" }}
|
clientId: {{ .Values.backend.oidc.clientId | default "NOT SET - REQUIRED" }}
|
||||||
|
iosRedirectUri: {{ .Values.backend.oidc.iosRedirectUri }}
|
||||||
|
|
||||||
|
JWT (iOS Bearer auth):
|
||||||
|
jwt.secret: {{ if .Values.backend.jwt.secret }}(set){{ else }}NOT SET - falling back to session.secret{{ end }}
|
||||||
@@ -56,8 +56,12 @@ spec:
|
|||||||
value: {{ .Values.backend.oidc.clientId | quote }}
|
value: {{ .Values.backend.oidc.clientId | quote }}
|
||||||
- name: OIDC_REDIRECT_URI
|
- name: OIDC_REDIRECT_URI
|
||||||
value: {{ (index .Values.ingress.hosts 0).host | printf "https://%s/api/auth/callback" | quote }}
|
value: {{ (index .Values.ingress.hosts 0).host | printf "https://%s/api/auth/callback" | quote }}
|
||||||
|
- name: OIDC_IOS_REDIRECT_URI
|
||||||
|
value: {{ .Values.backend.oidc.iosRedirectUri | quote }}
|
||||||
- name: SESSION_SECRET
|
- name: SESSION_SECRET
|
||||||
value: {{ .Values.backend.session.secret | quote }}
|
value: {{ .Values.backend.session.secret | quote }}
|
||||||
|
- name: JWT_SECRET
|
||||||
|
value: {{ .Values.backend.jwt.secret | quote }}
|
||||||
- name: APP_URL
|
- name: APP_URL
|
||||||
value: {{ (index .Values.ingress.hosts 0).host | printf "https://%s" | quote }}
|
value: {{ (index .Values.ingress.hosts 0).host | printf "https://%s" | quote }}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -41,11 +41,21 @@ backend:
|
|||||||
oidc:
|
oidc:
|
||||||
issuerUrl: ""
|
issuerUrl: ""
|
||||||
clientId: ""
|
clientId: ""
|
||||||
|
# Redirect URI registered in the IDP for the iOS native app.
|
||||||
|
# Must match the custom URL scheme configured in the iOS app.
|
||||||
|
iosRedirectUri: "timetracker://oauth/callback"
|
||||||
|
|
||||||
# Session configuration
|
# Session configuration
|
||||||
session:
|
session:
|
||||||
secret: "change-this-secret-in-production"
|
secret: "change-this-secret-in-production"
|
||||||
|
|
||||||
|
# JWT configuration (for iOS Bearer token auth)
|
||||||
|
# jwt.secret is used to sign backend-issued JWTs for the iOS app.
|
||||||
|
# If left empty it falls back to session.secret.
|
||||||
|
# Set this to a dedicated secret in production.
|
||||||
|
jwt:
|
||||||
|
secret: ""
|
||||||
|
|
||||||
env:
|
env:
|
||||||
nodeEnv: production
|
nodeEnv: production
|
||||||
port: 3001
|
port: 3001
|
||||||
|
|||||||
Reference in New Issue
Block a user