Compare commits

..
4 Commits
Author SHA1 Message Date
soconnor 1e7174fa60 Add permanent account deletion 2026-08-15 13:46:42 -04:00
soconnor 5f6987b84b Restore public demo review password 2026-08-15 00:17:57 -04:00
soconnor 66a53f25f2 Harden demo access and restore clean checks 2026-08-14 16:45:02 -04:00
soconnor 29d7b498ae Add business logo branding support 2026-08-14 16:26:43 -04:00
42 changed files with 1304 additions and 289 deletions
+7 -4
View File
@@ -82,12 +82,15 @@ bun run db:push # fast iteration during development
# bun run db:migrate # same migrations the Docker image runs in production # bun run db:migrate # same migrations the Docker image runs in production
``` ```
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`: **Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated `demo@example.com` account (`db:push` does not) with the public password `demo123`.
- Email: `demo@example.com` To rotate the credential temporarily, provision a private password:
- Password: `demo123`
The account includes a sample business, clients, and invoices (draft, sent, and paid). ```bash
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
```
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish a private replacement password. The account includes a sample business, clients, and invoices (draft, sent, and paid).
### 4. Run ### 4. Run
+125 -43
View File
@@ -53,19 +53,21 @@
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.9", "next": "^16.2.12",
"pg": "8.21.0", "pg": "8.21.0",
"react": "^19.2.7", "react": "^19.2.8",
"react-colorful": "^5.7.0", "react-colorful": "^5.7.0",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.7", "react-dom": "^19.2.8",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"resend": "^4.8.0", "resend": "^4.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"trpc": "^0.11.3",
"zod": "^3.25.76", "zod": "^3.25.76",
}, },
"devDependencies": { "devDependencies": {
@@ -96,8 +98,8 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
"esbuild",
"sharp", "sharp",
"esbuild",
"unrs-resolver", "unrs-resolver",
], ],
"packages": { "packages": {
@@ -327,53 +329,57 @@
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -387,25 +393,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="],
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
@@ -821,6 +827,8 @@
"axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="], "axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="],
"axios": ["axios@0.19.2", "", { "dependencies": { "follow-redirects": "1.5.10" } }, "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
@@ -1065,6 +1073,8 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.5.10", "", { "dependencies": { "debug": "=3.1.0" } }, "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
@@ -1311,7 +1321,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], "next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
@@ -1435,13 +1445,13 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="], "react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="], "react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="],
@@ -1501,7 +1511,7 @@
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
@@ -1513,7 +1523,7 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1585,6 +1595,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trpc": ["trpc@0.11.3", "", { "dependencies": { "axios": "^0.19.2" } }, "sha512-vfj6WrxYk8XDZzCsFNLwo5WhlKi4IYmVRzRgRQAlcK8zH4sY0yIAlJ7Nd1lZcGFe985GfP2LZLoEsCrMsIl/tA=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
@@ -1663,6 +1675,10 @@
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.2.2", "", {}, "sha512-xlgQcYROGFgKg5FY7ZLppFmG7rR5Hkmz7tgDuQeR79i5KhKRjr2QC9xsBG2qEGPJJjf9bxzg/NMW2hEUWs5OnA=="], "@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.2.2", "", {}, "sha512-xlgQcYROGFgKg5FY7ZLppFmG7rR5Hkmz7tgDuQeR79i5KhKRjr2QC9xsBG2qEGPJJjf9bxzg/NMW2hEUWs5OnA=="],
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -1677,6 +1693,10 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@react-email/render/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "@react-email/render/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
@@ -1725,10 +1745,16 @@
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"follow-redirects/debug": ["debug@3.1.0", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g=="],
"fontkit/@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], "fontkit/@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1737,14 +1763,16 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], "pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
"react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@@ -1795,10 +1823,64 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"follow-redirects/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"next/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"next/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"next/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"next/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoStorageKey" varchar(500);
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoMimeType" varchar(100);
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,8 @@
-- 0025 was silently skipped on databases that already had a later migration
-- timestamp recorded (from a since-removed feature's migrations). Re-apply
-- idempotently so both that DB and any fresh install end up with the column.
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
-- Drop the orphaned table from the business-document-vault feature that was
-- removed earlier this session (its migrations ran on this DB before removal).
DROP TABLE IF EXISTS "beenvoice_business_document" CASCADE;
@@ -0,0 +1,17 @@
-- The original App Review credential was committed publicly in migration 0014.
-- Rotate it to an unknown value and invalidate its sessions. Use
-- `bun run demo:provision` with a private DEMO_ACCOUNT_PASSWORD when review
-- access is needed.
UPDATE "beenvoice_user"
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
"updatedAt" = NOW()
WHERE "id" = 'a0000000-0000-4000-8000-000000000001';
UPDATE "beenvoice_account"
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
"updatedAt" = NOW()
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
AND "providerId" = 'credential';
DELETE FROM "beenvoice_session"
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
@@ -0,0 +1,16 @@
-- Restore the public App Store review credential for the seeded demo account.
-- Password: demo123
UPDATE "beenvoice_user"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "id" = 'a0000000-0000-4000-8000-000000000001'
AND "email" = 'demo@example.com';
UPDATE "beenvoice_account"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
AND "providerId" = 'credential';
DELETE FROM "beenvoice_session"
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
+42
View File
@@ -156,12 +156,54 @@
"tag": "0021_audit_log", "tag": "0021_audit_log",
"breakpoints": true "breakpoints": true
}, },
{
"idx": 22,
"version": "7",
"when": 1782100000000,
"tag": "0022_expense_business_receipts",
"breakpoints": true
},
{ {
"idx": 23, "idx": 23,
"version": "7", "version": "7",
"when": 1782200000000, "when": 1782200000000,
"tag": "0023_invoice_item_time_entry", "tag": "0023_invoice_item_time_entry",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1783700000000,
"tag": "0024_business_logo_upload",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1783800000000,
"tag": "0025_business_hide_name_with_logo",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1784000000000,
"tag": "0026_business_hide_name_with_logo_fix",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1786740000000,
"tag": "0027_disable_public_demo_password",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1786766968000,
"tag": "0028_enable_public_demo_password",
"breakpoints": true
} }
] ]
} }
+6 -3
View File
@@ -12,6 +12,7 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"demo:provision": "bun scripts/provision-demo-account.ts",
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
@@ -75,19 +76,21 @@
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.9", "next": "^16.2.12",
"pg": "8.21.0", "pg": "8.21.0",
"react": "^19.2.7", "react": "^19.2.8",
"react-colorful": "^5.7.0", "react-colorful": "^5.7.0",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.7", "react-dom": "^19.2.8",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"resend": "^4.8.0", "resend": "^4.8.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"trpc": "^0.11.3",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+61
View File
@@ -0,0 +1,61 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
import { Pool } from "pg";
const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const password = process.env.DEMO_ACCOUNT_PASSWORD?.trim();
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
if (!password || password.length < 12) {
throw new Error("DEMO_ACCOUNT_PASSWORD must be at least 12 characters");
}
const pool = new Pool({ connectionString: databaseUrl, ssl: false });
try {
const passwordHash = await bcrypt.hash(password, 12);
const client = await pool.connect();
try {
await client.query("BEGIN");
const userResult = await client.query(
`UPDATE "beenvoice_user"
SET "password" = $1, "updatedAt" = NOW()
WHERE "id" = $2`,
[passwordHash, DEMO_USER_ID],
);
const accountResult = await client.query(
`UPDATE "beenvoice_account"
SET "password" = $1, "updatedAt" = NOW()
WHERE "userId" = $2 AND "providerId" = 'credential'`,
[passwordHash, DEMO_USER_ID],
);
if (userResult.rowCount !== 1 || accountResult.rowCount !== 1) {
throw new Error(
"Demo account is missing. Apply database migrations before provisioning it.",
);
}
await client.query(`DELETE FROM "beenvoice_session" WHERE "userId" = $1`, [
DEMO_USER_ID,
]);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
console.log(
"Demo review account provisioned; previous sessions were invalidated.",
);
} finally {
await pool.end();
}
+32 -1
View File
@@ -9,7 +9,7 @@ const drizzleDir = path.resolve(import.meta.dir, "../drizzle");
const journalPath = path.join(drizzleDir, "meta/_journal.json"); const journalPath = path.join(drizzleDir, "meta/_journal.json");
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as { const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: Array<{ idx: number; tag: string }>; entries: Array<{ idx: number; tag: string; when: number }>;
}; };
const sqlTags = readdirSync(drizzleDir) const sqlTags = readdirSync(drizzleDir)
@@ -26,6 +26,27 @@ const idxSequence = journal.entries.map((entry) => entry.idx);
const expectedIdx = journal.entries.map((_, i) => i); const expectedIdx = journal.entries.map((_, i) => i);
const badIdx = idxSequence.some((idx, i) => idx !== expectedIdx[i]); const badIdx = idxSequence.some((idx, i) => idx !== expectedIdx[i]);
// drizzle's migrator gates on the single highest `when` already recorded in
// the target DB — it doesn't check hashes per-migration. If `when` values
// ever go non-increasing (e.g. a migration got deleted/renumbered after
// being applied to some environment), later entries can silently be skipped
// forever even though drizzle reports success. Keep this strictly increasing.
//
// 0008 and 0011 are known pre-existing exceptions from before this check
// existed (see git log on those files) — both already guard every statement
// with IF NOT EXISTS specifically because of this, so a skip is harmless.
// Don't add new exceptions here; fix the timestamp instead.
const KNOWN_NON_MONOTONIC_TAGS = new Set([
"0008_payments_recurring_public_links",
"0011_time_entry_invoice_id",
]);
const nonMonotonic = journal.entries.some(
(entry, i) =>
i > 0 &&
entry.when <= journal.entries[i - 1]!.when &&
!KNOWN_NON_MONOTONIC_TAGS.has(entry.tag),
);
let failed = false; let failed = false;
if (missingFromJournal.length > 0) { if (missingFromJournal.length > 0) {
@@ -45,6 +66,16 @@ if (badIdx) {
failed = true; failed = true;
} }
if (nonMonotonic) {
console.error(
"[verify-drizzle-journal] Journal `when` timestamps are not strictly increasing. " +
"drizzle-orm's migrator only compares against the single highest `when` already " +
"applied in the target DB, so a lower or equal value here can cause migrations to " +
"be silently skipped on databases that already ran a later timestamp.",
);
failed = true;
}
if (failed) { if (failed) {
process.exit(1); process.exit(1);
} }
@@ -0,0 +1,78 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { businesses } from "~/server/db/schema";
export const runtime = "nodejs";
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
// Intentionally unauthenticated: a business logo must be viewable on public,
// token-based invoice pages without a session. Business IDs are random
// UUIDs, so this only serves images to callers who already know the ID.
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ businessId: string }> },
) {
const { businessId } = await params;
const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true },
});
if (!business?.logoStorageKey || !business.logoMimeType) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng =
new URL(req.url).searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
try {
const body = await getObject(business.logoStorageKey);
if (wantsPng) {
const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml";
// SVG is vector: rasterize at a high density so the PNG stays crisp at
// the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster)
// case — for SVG it would cap us at whatever tiny canvas the source's
// intrinsic viewBox implies, even though the vector has no such limit.
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
.resize({
width: 1024,
height: 1024,
fit: "inside",
withoutEnlargement: !isSvg,
})
.png()
.toBuffer();
return new NextResponse(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
}
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": business.logoMimeType,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[business-logo] Failed to serve logo", {
backendError: error,
businessId,
wantsPng,
});
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
}
}
+34 -5
View File
@@ -7,7 +7,7 @@ import { generateInvoicePDFBlob } from "~/lib/pdf-export";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET( export async function GET(
_request: Request, request: Request,
{ params }: { params: Promise<{ token: string }> }, { params }: { params: Promise<{ token: string }> },
) { ) {
const { token } = await params; const { token } = await params;
@@ -16,7 +16,28 @@ export async function GET(
where: eq(invoices.publicToken, token), where: eq(invoices.publicToken, token),
with: { with: {
client: true, client: true,
business: true, // Explicit allowlist: token-based public route — never fetch
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
business: {
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
website: true,
taxId: true,
logoStorageKey: true,
logoMimeType: true,
hideNameWithLogo: true,
},
},
items: { items: {
orderBy: (i, { asc }) => [ orderBy: (i, { asc }) => [
asc(i.date), asc(i.date),
@@ -39,10 +60,16 @@ export async function GET(
where: eq(platformSettings.id, "global"), where: eq(platformSettings.id, "global"),
}); });
const pdfBlob = await generateInvoicePDFBlob(invoice, { const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined, pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor, pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined, pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans" | "sans"
| "serif" | "serif"
@@ -51,7 +78,9 @@ export async function GET(
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
}); },
{ logoBaseUrl: new URL(request.url).origin },
);
const buffer = await pdfBlob.arrayBuffer(); const buffer = await pdfBlob.arrayBuffer();
const filename = `invoice-${invoice.invoiceNumber}.pdf`; const filename = `invoice-${invoice.invoiceNumber}.pdf`;
@@ -74,9 +74,20 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{business.logoStorageKey ? (
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
<img
src={`/api/business-logo/${business.id}`}
alt={`${business.name} logo`}
className="h-full w-auto max-w-full object-contain"
/>
</div>
) : (
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" /> <Building className="text-primary h-5 w-5" />
</div> </div>
)}
<span>Business Information</span> <span>Business Information</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -34,6 +34,7 @@ interface Business {
website: string | null; website: string | null;
taxId: string | null; taxId: string | null;
logoUrl: string | null; logoUrl: string | null;
logoStorageKey: string | null;
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
@@ -86,8 +87,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original; const business = row.original;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex"> <div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
{business.logoStorageKey ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
<img
src={`/api/business-logo/${business.id}`}
alt=""
className="h-full w-full object-contain"
/>
) : (
<Building className="text-primary h-4 w-4" /> <Building className="text-primary h-4 w-4" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{business.name}</p> <p className="truncate font-medium">{business.name}</p>
+10
View File
@@ -377,6 +377,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{invoice.business.logoStorageKey && (
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
<img
src={`/api/business-logo/${invoice.business.id}`}
alt={`${invoice.business.name} logo`}
className="h-full w-auto max-w-full object-contain"
/>
</div>
)}
<h3 className="text-foreground text-xl font-semibold"> <h3 className="text-foreground text-xl font-semibold">
{invoice.business.name} {invoice.business.name}
</h3> </h3>
@@ -204,9 +204,12 @@ export default function SendEmailPage() {
: undefined, : undefined,
business: invoiceData.business business: invoiceData.business
? { ? {
id: invoiceData.business.id,
name: invoiceData.business.name, name: invoiceData.business.name,
nickname: invoiceData.business.nickname, nickname: invoiceData.business.nickname,
email: invoiceData.business.email, email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType,
} }
: undefined, : undefined,
items: invoiceData.items?.map((item) => ({ items: invoiceData.items?.map((item) => ({
@@ -162,8 +162,11 @@ export function SettingsContent({
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false); const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } = const {
useAppearance(); colorMode,
updateAppearance,
isUpdating: appearanceUpdating,
} = useAppearance();
const utils = api.useUtils(); const utils = api.useUtils();
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(); const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({ const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
@@ -221,8 +224,11 @@ export function SettingsContent({
}; };
// Queries // Queries
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } = const {
api.settings.getProfile.useQuery(); data: profile,
refetch: refetchProfile,
isFetched: profileFetched,
} = api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin"; const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery(); const { data: dataStats } = api.settings.getDataStats.useQuery();
@@ -285,10 +291,13 @@ export function SettingsContent({
}, },
}); });
const deleteDataMutation = api.settings.deleteAllData.useMutation({ const deleteAccountMutation = api.settings.deleteAccount.useMutation({
onSuccess: () => { onSuccess: async () => {
toast.success("All data has been permanently deleted"); toast.success("Your account and data have been permanently deleted");
setDeleteConfirmText(""); setDeleteConfirmText("");
await authClient.signOut().catch(() => undefined);
router.replace("/login");
router.refresh();
}, },
onError: (error: { message: string }) => { onError: (error: { message: string }) => {
toast.error(`Delete failed: ${error.message}`); toast.error(`Delete failed: ${error.message}`);
@@ -401,12 +410,12 @@ export function SettingsContent({
} }
}; };
const handleDeleteAllData = () => { const handleDeleteAccount = () => {
if (deleteConfirmText !== "delete all my data") { if (deleteConfirmText !== "DELETE MY ACCOUNT") {
toast.error("Please type 'delete all my data' to confirm"); toast.error("Please type 'DELETE MY ACCOUNT' to confirm");
return; return;
} }
deleteDataMutation.mutate({ confirmText: deleteConfirmText }); deleteAccountMutation.mutate({ confirmText: deleteConfirmText });
}; };
// Set initial name value once when profile loads // Set initial name value once when profile loads
@@ -706,10 +715,7 @@ export function SettingsContent({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{colorModes.map((modeOption) => ( {colorModes.map((modeOption) => (
<SelectItem <SelectItem key={modeOption.value} value={modeOption.value}>
key={modeOption.value}
value={modeOption.value}
>
{modeOption.label} {modeOption.label}
</SelectItem> </SelectItem>
))} ))}
@@ -1283,37 +1289,43 @@ export function SettingsContent({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto"> <Button variant="destructive" className="w-full sm:w-auto">
Delete All Data Delete Account
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete This action cannot be undone. It permanently deletes your
your account and remove your data from our servers. account, invoices, clients, businesses, expenses, time
entries, uploaded files, and sign-in data from our servers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="my-4 space-y-2"> <div className="my-4 space-y-2">
<Label htmlFor="confirm-delete"> <Label htmlFor="confirm-delete">
Type <span className="font-bold">delete all my data</span>{" "} Type <span className="font-bold">DELETE MY ACCOUNT</span> to
to confirm confirm
</Label> </Label>
<Input <Input
id="confirm-delete" id="confirm-delete"
value={deleteConfirmText} value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)} onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete all my data" placeholder="DELETE MY ACCOUNT"
/> />
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDeleteAllData} onClick={handleDeleteAccount}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteConfirmText !== "delete all my data"} disabled={
deleteConfirmText !== "DELETE MY ACCOUNT" ||
deleteAccountMutation.isPending
}
> >
Delete Account {deleteAccountMutation.isPending
? "Deleting…"
: "Delete Account"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+20 -4
View File
@@ -92,6 +92,8 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})` ? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name : invoice.business.name
: null; : null;
const hasLogo = Boolean(invoice.business?.logoStorageKey);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return ( return (
<div className="min-h-screen bg-gray-50 py-10 px-4"> <div className="min-h-screen bg-gray-50 py-10 px-4">
@@ -99,11 +101,25 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Card */} {/* Card */}
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
{/* Header */} {/* Header */}
<div className="bg-gray-900 px-8 py-6"> <div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p> {hasLogo && (
{invoice.business?.email && ( // Uploaded SVGs are sanitized and served by our route. next/image's
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p> // optimizer intentionally rejects SVG, so a native img is required.
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/business-logo/${invoice.business!.id}`}
alt=""
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
/>
)} )}
<div className="min-w-0">
{!hideName && (
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
)}
{invoice.business?.email && (
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
)}
</div>
</div> </div>
{/* Body */} {/* Body */}
+2 -14
View File
@@ -8,6 +8,7 @@ import { getAppUrl } from "~/lib/app-url";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script"; import { UmamiScript } from "~/components/analytics/umami-script";
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
import { BrandBackground } from "~/components/layout/brand-background"; import { BrandBackground } from "~/components/layout/brand-background";
const siteTitle = `${brand.name} - Invoicing Made Simple`; const siteTitle = `${brand.name} - Invoicing Made Simple`;
@@ -63,20 +64,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`} className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
> >
<head> <head>
<script <AppearanceInitScript />
id="appearance-init"
dangerouslySetInnerHTML={{
__html: `
try {
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`,
}}
/>
</head> </head>
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased"> <body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<BrandBackground /> <BrandBackground />
+178
View File
@@ -7,12 +7,15 @@ import {
EyeOff, EyeOff,
FileText, FileText,
Globe, Globe,
ImageIcon,
Info, Info,
Key, Key,
Loader2, Loader2,
Mail, Mail,
Save, Save,
Star, Star,
Trash2,
Upload,
User, User,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -59,6 +62,7 @@ interface FormData {
country: string; country: string;
website: string; website: string;
taxId: string; taxId: string;
hideNameWithLogo: boolean;
isDefault: boolean; isDefault: boolean;
resendApiKey: string; resendApiKey: string;
resendDomain: string; resendDomain: string;
@@ -95,20 +99,31 @@ const initialFormData: FormData = {
country: "United States", country: "United States",
website: "", website: "",
taxId: "", taxId: "",
hideNameWithLogo: false,
isDefault: false, isDefault: false,
resendApiKey: "", resendApiKey: "",
resendDomain: "", resendDomain: "",
emailFromName: "", emailFromName: "",
}; };
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/svg+xml",
]);
export function BusinessForm({ businessId, mode }: BusinessFormProps) { export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils();
const [formData, setFormData] = useState<FormData>(initialFormData); const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
// Fetch business data if editing // Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = const { data: business, isLoading: isLoadingBusiness } =
@@ -149,6 +164,62 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}, },
}); });
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: async () => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo updated");
},
onError: (error) => {
toast.error(error.message || "Failed to upload logo");
},
onSettled: () => setIsUploadingLogo(false),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: async () => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo removed");
},
onError: (error) => {
toast.error(error.message || "Failed to remove logo");
},
});
const handleLogoFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !businessId) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo must be a PNG, JPEG, WebP, or SVG image");
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo must be 5MB or less");
return;
}
setIsUploadingLogo(true);
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(typeof result === "string" ? (result.split(",")[1] ?? "") : "");
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
uploadLogo.mutate({
id: businessId,
filename: file.name,
mimeType: file.type,
data,
});
};
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business. // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false); setInitialized(false);
@@ -178,6 +249,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: business.country ?? "United States", country: business.country ?? "United States",
website: business.website ?? "", website: business.website ?? "",
taxId: business.taxId ?? "", taxId: business.taxId ?? "",
hideNameWithLogo: business.hideNameWithLogo ?? false,
isDefault: business.isDefault ?? false, isDefault: business.isDefault ?? false,
resendApiKey: "", // Never pre-fill API key for security resendApiKey: "", // Never pre-fill API key for security
resendDomain: emailConfig?.resendDomain ?? "", resendDomain: emailConfig?.resendDomain ?? "",
@@ -338,6 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: dataToSubmit.country, country: dataToSubmit.country,
website: dataToSubmit.website, website: dataToSubmit.website,
taxId: dataToSubmit.taxId, taxId: dataToSubmit.taxId,
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
isDefault: dataToSubmit.isDefault, isDefault: dataToSubmit.isDefault,
}; };
@@ -376,6 +449,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: dataToSubmit.country, country: dataToSubmit.country,
website: dataToSubmit.website, website: dataToSubmit.website,
taxId: dataToSubmit.taxId, taxId: dataToSubmit.taxId,
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
isDefault: dataToSubmit.isDefault, isDefault: dataToSubmit.isDefault,
}; };
@@ -649,6 +723,110 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Logo */}
{mode === "edit" && businessId && (
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<ImageIcon className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Logo</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Shown on invoices sent to your clients. PNG, JPEG,
WebP, or SVG, up to 5MB.
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2">
{business?.logoStorageKey ? (
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
<img
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business.name} logo`}
className="h-full w-auto max-w-full object-contain"
/>
) : (
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploadingLogo}
onClick={() =>
document.getElementById("logo-upload-input")?.click()
}
>
{isUploadingLogo ? (
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
) : (
<Upload className="h-4 w-4 sm:mr-2" />
)}
<span className="hidden sm:inline">
{business?.logoStorageKey
? "Replace logo"
: "Upload logo"}
</span>
</Button>
{business?.logoStorageKey && (
<Button
type="button"
variant="outline"
size="sm"
disabled={removeLogo.isPending}
onClick={() =>
businessId && removeLogo.mutate({ id: businessId })
}
>
<Trash2 className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Remove</span>
</Button>
)}
<input
id="logo-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={handleLogoFileSelected}
/>
</div>
</div>
{business?.logoStorageKey && (
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
htmlFor="hideNameWithLogo"
className="text-base font-medium"
>
Hide business name on invoices
</Label>
<p className="text-muted-foreground text-sm">
Show only the logo in the invoice header useful
if your logo already includes your business name.
</p>
</div>
<Switch
id="hideNameWithLogo"
checked={formData.hideNameWithLogo}
onCheckedChange={(checked) =>
handleInputChange("hideNameWithLogo", checked)
}
disabled={isSubmitting}
/>
</div>
)}
</CardContent>
</Card>
)}
{/* Address */} {/* Address */}
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
+3
View File
@@ -25,8 +25,11 @@ interface EmailPreviewProps {
email: string | null; email: string | null;
}; };
business?: { business?: {
id?: string;
name: string; name: string;
email: string | null; email: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
@@ -0,0 +1,29 @@
"use client";
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
// check) and returns null on the client, so the <script> element never
// enters the tree React reconciles during hydration — React 19 otherwise
// warns "Encountered a script tag while rendering React component" for any
// <script> it walks while hydrating, even one from next/script. Same fix
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
const APPEARANCE_INIT_SOURCE = `
try {
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`;
export function AppearanceInitScript() {
if (typeof window !== "undefined") return null;
return (
<script
id="appearance-init"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
/>
);
}
+77 -58
View File
@@ -1,7 +1,4 @@
import { import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
LEGAL_PRIVACY_EMAIL,
LEGAL_WEBSITE,
} from "~/lib/legal";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
import { import {
LegalDocument, LegalDocument,
@@ -16,9 +13,9 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
This Privacy Policy explains how {brand.name} collects, uses, and protects This Privacy Policy explains how {brand.name} collects, uses, and
information when you use our invoicing platform, including the web app and mobile protects information when you use our invoicing platform, including
app (the Service). the web app and mobile app (the Service).
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
If you have questions about this policy, email us at{" "} If you have questions about this policy, email us at{" "}
@@ -33,19 +30,21 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
When you create an account and use the Service, you provide information such as When you create an account and use the Service, you provide
your name, email address, business details, client records, invoice content, and information such as your name, email address, business details, client
time entries. This is the data you enter to run your invoicing workflow. records, invoice content, and time entries. This is the data you enter
to run your invoicing workflow.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
You may also add payment instructions that appear on invoices, such as bank You may also add payment instructions that appear on invoices, such as
transfer details. We do not process card payments on your behalf. bank transfer details. We do not process card payments on your behalf.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We also collect some technical information automatically so the Service stays We also collect some technical information automatically so the
secure and reliable. This can include your IP address, device and browser or app Service stays secure and reliable. This can include your IP address,
details, log and diagnostic data, and session cookies that keep you signed in. device and browser or app details, log and diagnostic data, and
Some deployments may use optional, privacy-focused analytics. session cookies that keep you signed in. Some deployments may use
optional, privacy-focused analytics.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -56,9 +55,10 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use your information to provide and operate the Service, authenticate your We use your information to provide and operate the Service,
account, send transactional messages such as password resets, respond to support authenticate your account, send transactional messages such as
requests, monitor security and performance, and meet legal obligations. password resets, respond to support requests, monitor security and
performance, and meet legal obligations.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -69,24 +69,24 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We do not sell your personal information. We share it only when needed to run the We do not sell your personal information. We share it only when needed
Service or when the law requires it. to run the Service or when the law requires it.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We work with service providers that host our infrastructure, deliver transactional We work with service providers that host our infrastructure, deliver
email, support single sign-on when enabled on your instance, and optionally provide transactional email, support single sign-on when enabled on your
privacy-focused analytics. These vendors may process your information only to instance, and optionally provide privacy-focused analytics. These
perform services for us. vendors may process your information only to perform services for us.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We may disclose information if we believe it is reasonably necessary to comply with We may disclose information if we believe it is reasonably necessary
law, respond to a valid legal request, or protect the security and integrity of the to comply with law, respond to a valid legal request, or protect the
Service. security and integrity of the Service.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
If we are involved in a merger, acquisition, or sale of assets, your information If we are involved in a merger, acquisition, or sale of assets, your
may be transferred as part of that transaction, subject to continued protection information may be transferred as part of that transaction, subject to
consistent with this policy. continued protection consistent with this policy.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -97,14 +97,25 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use reasonable safeguards to protect information, including encryption in We use reasonable safeguards to protect information, including
transit, access controls, and secure authentication. No method of transmission or encryption in transit, access controls, and secure authentication. No
storage is completely secure. method of transmission or storage is completely secure.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We retain information for as long as you have an account or as needed to provide We retain information for as long as you have an account or as needed
the Service. We may keep certain records longer when required by law or for to provide the Service. We may keep certain records longer when
legitimate purposes such as fraud prevention or dispute resolution. required by law or for legitimate purposes such as fraud prevention or
dispute resolution.
</LegalParagraph>
<LegalParagraph>
You can permanently delete your account from Settings in the mobile
app or web app. Account deletion removes your account record and
associated Service data, including invoices, clients, businesses,
expenses, time entries, uploaded files, access keys, and active
sessions. The action cannot be undone. Limited information may be
retained only when required by law, and residual copies may remain in
secure backups until those backups are overwritten through our normal
retention cycle.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -115,13 +126,16 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
Depending on where you live, you may have the right to access, correct, delete, or Depending on where you live, you may have the right to access,
export your personal information, or to object to or restrict certain processing. correct, delete, or export your personal information, or to object to
or restrict certain processing.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
To exercise these rights, contact us at{" "} You can delete your account directly in Settings. To exercise another
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will privacy right, or if you cannot access your account, contact us at{" "}
respond within a reasonable timeframe and as required by applicable law. <a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
We will respond within a reasonable timeframe and as required by
applicable law.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -132,13 +146,13 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
We use cookies and similar technologies to keep you signed in, remember We use cookies and similar technologies to keep you signed in,
preferences such as theme, and, when enabled on a deployment, measure usage with remember preferences such as theme, and, when enabled on a deployment,
privacy-focused analytics. measure usage with privacy-focused analytics.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
You can control cookies through your browser settings. If you disable essential You can control cookies through your browser settings. If you disable
cookies, some parts of the Service may not work correctly. essential cookies, some parts of the Service may not work correctly.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -149,22 +163,26 @@ const sections: LegalSection[] = [
children: ( children: (
<> <>
<LegalParagraph> <LegalParagraph>
The Service may link to third-party websites or integrate with services you The Service may link to third-party websites or integrate with
configure, such as single sign-on. Those services have their own privacy policies, services you configure, such as single sign-on. Those services have
and we are not responsible for their practices. their own privacy policies, and we are not responsible for their
practices.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
The Service is not intended for children under 13. If you believe a child has The Service is not intended for children under 13. If you believe a
provided us personal information, contact us and we will delete it. child has provided us personal information, contact us and we will
delete it.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
Your information may be processed in countries other than your own. Where required, Your information may be processed in countries other than your own.
we use appropriate safeguards for international transfers. Where required, we use appropriate safeguards for international
transfers.
</LegalParagraph> </LegalParagraph>
<LegalParagraph> <LegalParagraph>
We may update this policy from time to time. If we make material changes, we will We may update this policy from time to time. If we make material
post the updated policy on the Service and may notify you by email. Continued use changes, we will post the updated policy on the Service and may notify
after changes take effect means you accept the updated policy. you by email. Continued use after changes take effect means you accept
the updated policy.
</LegalParagraph> </LegalParagraph>
</> </>
), ),
@@ -176,7 +194,8 @@ const sections: LegalSection[] = [
<> <>
<LegalParagraph> <LegalParagraph>
For privacy questions or requests, email{" "} For privacy questions or requests, email{" "}
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or visit{" "} <a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or
visit{" "}
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer"> <a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")} {LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
</a> </a>
@@ -19,7 +19,13 @@ export function AppearanceProviderSynced({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode); // Lazy initializer so the first render already matches what the inline
// appearance-init script set on <html> — a separate mount effect here
// would run one render behind it, transiently flashing (and persisting)
// colorMode back to the default before the effect's own state update lands.
const [colorMode, setColorMode] = useState<ColorMode>(
() => readStoredColorMode() ?? defaultColorMode,
);
const serverHydratedRef = useRef(false); const serverHydratedRef = useRef(false);
const utils = api.useUtils(); const utils = api.useUtils();
const updateMutation = api.settings.updateColorMode.useMutation({ const updateMutation = api.settings.updateColorMode.useMutation({
@@ -41,14 +47,6 @@ export function AppearanceProviderSynced({
}, },
); );
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => { useEffect(() => {
if (!serverColorMode?.colorMode) return; if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return; if (serverHydratedRef.current) return;
@@ -63,15 +63,13 @@ export function AppearanceProvider({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode); // Lazy initializer so the first render already matches what the inline
// appearance-init script set on <html> — a separate mount effect here
useEffect(() => { // would run one render behind it, transiently flashing (and persisting)
const stored = readStoredColorMode(); // colorMode back to the default before the effect's own state update lands.
if (stored) { const [colorMode, setColorMode] = useState<ColorMode>(
// eslint-disable-next-line react-hooks/set-state-in-effect () => readStoredColorMode() ?? defaultColorMode,
setColorMode(stored); );
}
}, []);
useEffect(() => { useEffect(() => {
applyColorMode(colorMode); applyColorMode(colorMode);
+19
View File
@@ -10,6 +10,25 @@ export function getAppUrl(): string {
return `http://localhost:${process.env.PORT ?? 3000}`; return `http://localhost:${process.env.PORT ?? 3000}`;
} }
/**
* Origin derived from an incoming request's own headers — robust against
* NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL drifting from the port the server is
* actually reachable on (e.g. local dev when the configured port is taken).
* Falls back to getAppUrl() if the request has no usable host header.
*/
export function getRequestOrigin(headers: Headers): string {
const host = headers.get("x-forwarded-host") ?? headers.get("host");
if (!host) return getAppUrl();
const forwardedProto = headers.get("x-forwarded-proto");
const protocol =
forwardedProto ?? (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")
? "http"
: "https");
return `${protocol}://${host}`;
}
/** Hostname for display (e.g. marketing browser chrome). */ /** Hostname for display (e.g. marketing browser chrome). */
export function getAppHost(): string { export function getAppHost(): string {
try { try {
+5
View File
@@ -39,6 +39,11 @@ export const auth = betterAuth({
secret: process.env.AUTH_SECRET, secret: process.env.AUTH_SECRET,
advanced: { advanced: {
trustedProxyHeaders: true, trustedProxyHeaders: true,
// Login from a LAN IP, ngrok tunnel, or the Expo dev client hits the API
// from an Origin that's rarely worth adding to trustedOrigins ahead of
// time. Skip the Origin/CSRF check in dev only; production still
// enforces it via trustedOrigins above.
...(env.NODE_ENV === "development" ? { disableCSRFCheck: true } : {}),
}, },
rateLimit: { rateLimit: {
enabled: true, enabled: true,
+21
View File
@@ -1,5 +1,21 @@
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
baseUrl: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
interface InvoiceEmailTemplateProps { interface InvoiceEmailTemplateProps {
invoice: { invoice: {
invoiceNumber: string; invoiceNumber: string;
@@ -14,6 +30,7 @@ interface InvoiceEmailTemplateProps {
email: string | null; email: string | null;
}; };
business?: { business?: {
id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
email?: string | null; email?: string | null;
@@ -24,6 +41,8 @@ interface InvoiceEmailTemplateProps {
state?: string | null; state?: string | null;
postalCode?: string | null; postalCode?: string | null;
country?: string | null; country?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
} | null; } | null;
items: Array<{ items: Array<{
date: Date; date: Date;
@@ -73,6 +92,7 @@ export function generateInvoiceEmailTemplate({
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100); const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
const businessAddress = invoice.business const businessAddress = invoice.business
? [ ? [
@@ -409,6 +429,7 @@ export function generateInvoiceEmailTemplate({
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
${logoUrl ? `<img src="${logoUrl}" alt="${invoice.business?.name ?? ""}" style="max-height: 40px; max-width: 200px; margin-bottom: 12px;">` : ""}
<div class="header-content">Invoice ${invoice.invoiceNumber}</div> <div class="header-content">Invoice ${invoice.invoiceNumber}</div>
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div> <div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
</div> </div>
+5 -1
View File
@@ -56,7 +56,11 @@ export const navigationConfig: NavSection[] = [
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock }, { name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Entities", href: "/dashboard/entities", icon: Users }, { name: "Entities", href: "/dashboard/entities", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText }, { name: "Invoices", href: "/dashboard/invoices", icon: FileText },
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw }, {
name: "Recurring",
href: "/dashboard/invoices/recurring",
icon: RefreshCw,
},
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt }, { name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 }, { name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
], ],
+2 -1
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises"; import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path"; import path from "path";
import type * as S3ClientModule from "@aws-sdk/client-s3";
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/. // Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts"); const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
@@ -17,7 +18,7 @@ export function getStorageBackend(): "s3" | "local" {
return isS3Configured() ? "s3" : "local"; return isS3Configured() ? "s3" : "local";
} }
type S3Module = typeof import("@aws-sdk/client-s3"); type S3Module = typeof S3ClientModule;
let s3ModulePromise: Promise<S3Module> | null = null; let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null; let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
+63 -4
View File
@@ -75,6 +75,7 @@ export interface InvoiceData {
currency?: string | null; currency?: string | null;
notes?: string | null; notes?: string | null;
business?: { business?: {
id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
email?: string | null; email?: string | null;
@@ -87,6 +88,9 @@ export interface InvoiceData {
country?: string | null; country?: string | null;
website?: string | null; website?: string | null;
taxId?: string | null; taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null;
} | null; } | null;
client?: { client?: {
name: string; name: string;
@@ -843,14 +847,45 @@ function getColumnWidths(showRate: boolean) {
: { date: "15%", description: "48%", hours: "14%", amount: "23%" }; : { date: "15%", description: "48%", hours: "14%", amount: "23%" };
} }
// @react-pdf/renderer's Image component only reliably decodes PNG/JPEG.
// SVG and WebP logos are rasterized to PNG on the fly by the serving route
// (via ?format=png, using sharp) so every logo format still shows in the PDF.
function resolveBusinessLogoSrc(
business: InvoiceData["business"],
baseUrlOverride?: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
if (typeof window !== "undefined") {
return `${window.location.origin}${path}`;
}
// Server-side rendering has no window.location — callers that know their
// own request origin (e.g. a Route Handler) should pass baseUrlOverride,
// since NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL can drift from the port the
// server actually ends up running on (e.g. in local dev).
const base =
baseUrlOverride ??
process.env.NEXT_PUBLIC_APP_URL ??
process.env.BETTER_AUTH_URL;
return base ? `${base.replace(/\/$/, "")}${path}` : null;
}
// Dense header component (first page) // Dense header component (first page)
const DenseHeader: React.FC<{ const DenseHeader: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle; pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => { logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles; const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal"; const isMinimal = settings.pdfTemplate === "minimal";
const logoSrc = resolveBusinessLogoSrc(invoice.business, logoBaseUrl);
const hideName = Boolean(logoSrc && invoice.business?.hideNameWithLogo);
return ( return (
<View <View
@@ -860,6 +895,20 @@ const DenseHeader: React.FC<{
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]} style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
> >
<View style={styles.businessSection}> <View style={styles.businessSection}>
{logoSrc && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src={logoSrc}
style={{
width: 160,
height: 64,
marginBottom: 6,
objectFit: "contain",
objectPosition: "left",
}}
/>
)}
{!hideName && (
<Text <Text
style={[ style={[
styles.businessName, styles.businessName,
@@ -869,6 +918,7 @@ const DenseHeader: React.FC<{
> >
{invoice.business?.name ?? "Your Business Name"} {invoice.business?.name ?? "Your Business Name"}
</Text> </Text>
)}
{invoice.business?.email && ( {invoice.business?.email && (
<Text <Text
style={[ style={[
@@ -1390,7 +1440,8 @@ const TotalsSection: React.FC<{
export const InvoicePDF: React.FC<{ export const InvoicePDF: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings?: PDFGenerationSettings; settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => { logoBaseUrl?: string;
}> = ({ invoice, settings: inputSettings, logoBaseUrl }) => {
const settings = resolvePDFSettings(inputSettings); const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle( const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily, settings.pdfFontFamily,
@@ -1402,6 +1453,7 @@ export const InvoicePDF: React.FC<{
invoice={invoice} invoice={invoice}
settings={settings} settings={settings}
pdfStyles={pdfStyles} pdfStyles={pdfStyles}
logoBaseUrl={logoBaseUrl}
/> />
); );
}; };
@@ -1410,7 +1462,8 @@ const InvoicePDFDocument: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle; pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => { logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles } = pdfStyles; const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? []; const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
@@ -1426,6 +1479,7 @@ const InvoicePDFDocument: React.FC<{
> >
<DenseHeader <DenseHeader
invoice={invoice} invoice={invoice}
logoBaseUrl={logoBaseUrl}
settings={settings} settings={settings}
pdfStyles={pdfStyles} pdfStyles={pdfStyles}
/> />
@@ -1592,6 +1646,7 @@ export async function generateInvoicePDF(
export async function generateInvoicePDFBlob( export async function generateInvoicePDFBlob(
invoice: InvoiceData, invoice: InvoiceData,
settings?: PDFGenerationSettings, settings?: PDFGenerationSettings,
options?: { logoBaseUrl?: string },
): Promise<Blob> { ): Promise<Blob> {
try { try {
// Validate invoice data // Validate invoice data
@@ -1609,7 +1664,11 @@ export async function generateInvoicePDFBlob(
// Generate PDF blob // Generate PDF blob
const originalBlob = await pdf( const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />, <InvoicePDF
invoice={invoice}
settings={settings}
logoBaseUrl={options?.logoBaseUrl}
/>,
).toBlob(); ).toBlob();
// Validate blob // Validate blob
+38
View File
@@ -0,0 +1,38 @@
import "server-only";
/**
* Lightweight defense-in-depth pass over uploaded SVG markup before it is
* stored. Strips executable content (scripts, event handlers, external
* references) so a malicious SVG can't run script if it's ever rendered
* inline (dangerouslySetInnerHTML) rather than via <img src>. Not a full
* parser — good enough for a self-uploaded logo, not a substitute for
* treating SVG as active content from an untrusted source.
*/
export function sanitizeSvg(input: string): string {
let svg = input;
// Strip <script>...</script> blocks and self-closing <script/> tags.
svg = svg.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
svg = svg.replace(/<script\b[^>]*\/>/gi, "");
// Strip on* event handler attributes (onload, onclick, onerror, ...).
svg = svg.replace(/\son\w+\s*=\s*"[^"]*"/gi, "");
svg = svg.replace(/\son\w+\s*=\s*'[^']*'/gi, "");
svg = svg.replace(/\son\w+\s*=\s*[^\s>]+/gi, "");
// Strip javascript: URIs in href/xlink:href/src attributes.
svg = svg.replace(
/((?:xlink:href|href|src)\s*=\s*)"javascript:[^"]*"/gi,
'$1""',
);
svg = svg.replace(
/((?:xlink:href|href|src)\s*=\s*)'javascript:[^']*'/gi,
"$1''",
);
// Strip <foreignObject> (can embed arbitrary HTML) and <iframe>.
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
svg = svg.replace(/<iframe[\s\S]*?<\/iframe\s*>/gi, "");
return svg;
}
+5
View File
@@ -1,6 +1,11 @@
/** Stored on entries clocked in before empty descriptions were allowed. */ /** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services"; export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function normalizeOptionalId(value?: string | null): string | null {
const trimmed = value?.trim();
return trimmed == null || trimmed === "" ? null : trimmed;
}
export function resolveEffectiveHourlyRate( export function resolveEffectiveHourlyRate(
enteredRate: number, enteredRate: number,
client?: { defaultHourlyRate?: number | null } | null, client?: { defaultHourlyRate?: number | null } | null,
+7 -1
View File
@@ -17,7 +17,13 @@ export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Define API routes that should be handled separately // Define API routes that should be handled separately
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"]; const apiRoutes = [
"/api/auth",
"/api/trpc",
"/api/mcp",
"/api/i",
"/api/business-logo",
];
// Allow API routes to pass through // Allow API routes to pass through
if (apiRoutes.some((route) => pathname.startsWith(route))) { if (apiRoutes.some((route) => pathname.startsWith(route))) {
+134
View File
@@ -1,9 +1,20 @@
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { businesses } from "~/server/db/schema"; import { businesses } from "~/server/db/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
import { invoices } from "~/server/db/schema"; import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { deleteObject, putObject } from "~/lib/object-storage";
import { sanitizeSvg } from "~/lib/svg-sanitize";
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const allowedLogoMimeTypes = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/svg+xml",
]);
const businessSchema = z.object({ const businessSchema = z.object({
name: z name: z
@@ -28,6 +39,7 @@ const businessSchema = z.object({
website: z.string().url().optional().or(z.literal("")), website: z.string().url().optional().or(z.literal("")),
taxId: z.string().optional().or(z.literal("")), taxId: z.string().optional().or(z.literal("")),
logoUrl: z.string().optional().or(z.literal("")), logoUrl: z.string().optional().or(z.literal("")),
hideNameWithLogo: z.boolean().default(false),
isDefault: z.boolean().default(false), isDefault: z.boolean().default(false),
}); });
@@ -153,6 +165,7 @@ export const businessesRouter = createTRPCRouter({
input.logoUrl && input.logoUrl.trim() !== "" input.logoUrl && input.logoUrl.trim() !== ""
? input.logoUrl.trim() ? input.logoUrl.trim()
: null, : null,
hideNameWithLogo: input.hideNameWithLogo ?? false,
isDefault: input.isDefault ?? false, isDefault: input.isDefault ?? false,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
@@ -232,6 +245,7 @@ export const businessesRouter = createTRPCRouter({
updateData.logoUrl && updateData.logoUrl.trim() !== "" updateData.logoUrl && updateData.logoUrl.trim() !== ""
? updateData.logoUrl.trim() ? updateData.logoUrl.trim()
: null, : null,
hideNameWithLogo: updateData.hideNameWithLogo ?? false,
isDefault: updateData.isDefault ?? false, isDefault: updateData.isDefault ?? false,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -417,4 +431,124 @@ export const businessesRouter = createTRPCRouter({
hasApiKey: !!business[0].hasApiKey, hasApiKey: !!business[0].hasApiKey,
}; };
}), }),
// Upload (or replace) a business logo, shown on invoices
uploadLogo: protectedProcedure
.input(
z.object({
id: z.string(),
filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100),
data: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const [business] = await ctx.db
.select()
.from(businesses)
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
});
}
const mimeType = input.mimeType.toLowerCase().split(";")[0]!.trim();
if (!allowedLogoMimeTypes.has(mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
});
}
let body = Buffer.from(input.data, "base64");
if (!body.length || body.length > MAX_LOGO_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be between 1 byte and 5MB",
});
}
if (mimeType === "image/svg+xml") {
body = Buffer.from(sanitizeSvg(body.toString("utf8")), "utf8");
}
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
const previousStorageKey = business.logoStorageKey;
try {
await putObject(storageKey, body, mimeType);
} catch (error) {
console.error("[businesses.uploadLogo] Failed to store logo", {
backendError: error,
businessId: business.id,
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Logo storage is unavailable. Check the object-storage service and try again.",
cause: error,
});
}
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
logoStorageKey: storageKey,
logoMimeType: mimeType,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id))
.returning();
if (previousStorageKey) {
await deleteObject(previousStorageKey).catch(() => undefined);
}
return updatedBusiness;
}),
// Remove a business logo
removeLogo: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const [business] = await ctx.db
.select()
.from(businesses)
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
});
}
if (business.logoStorageKey) {
await deleteObject(business.logoStorageKey).catch(() => undefined);
}
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
.where(eq(businesses.id, business.id))
.returning();
return updatedBusiness;
}),
}); });
+8 -4
View File
@@ -5,7 +5,7 @@ import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email"; import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url"; import { getRequestOrigin } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -88,7 +88,9 @@ export const emailRouter = createTRPCRouter({
const settings = await ctx.db.query.platformSettings.findFirst({ const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"), where: eq(platformSettings.id, "global"),
}); });
const pdfBlob = await generateInvoicePDFBlob(invoice, { const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as pdfTemplate: settings?.pdfTemplate as
| "classic" | "classic"
| "minimal" | "minimal"
@@ -107,7 +109,9 @@ export const emailRouter = createTRPCRouter({
pdfFooterText: settings?.pdfFooterText, pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
}); },
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer()); pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully // Validate PDF was generated successfully
@@ -165,7 +169,7 @@ export const emailRouter = createTRPCRouter({
customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: getAppUrl(), baseUrl: getRequestOrigin(ctx.headers),
}); });
// Determine Resend instance and email configuration to use // Determine Resend instance and email configuration to use
+10 -4
View File
@@ -20,9 +20,15 @@ import {
RECEIPT_MAX_BYTES, RECEIPT_MAX_BYTES,
} from "~/lib/object-storage"; } from "~/lib/object-storage";
import { parseReceiptText } from "~/lib/receipt-parse"; import { parseReceiptText } from "~/lib/receipt-parse";
import type { db } from "~/server/db";
export { EXPENSE_CATEGORIES }; export { EXPENSE_CATEGORIES };
type ExpenseContext = {
db: typeof db;
session: { user: { id: string } };
};
const createExpenseSchema = z.object({ const createExpenseSchema = z.object({
date: z.date(), date: z.date(),
description: z.string().min(1, "Description is required"), description: z.string().min(1, "Description is required"),
@@ -43,7 +49,7 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
}); });
async function verifyClientAccess( async function verifyClientAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, ctx: ExpenseContext,
clientId: string, clientId: string,
) { ) {
const client = await ctx.db.query.clients.findFirst({ const client = await ctx.db.query.clients.findFirst({
@@ -62,7 +68,7 @@ async function verifyClientAccess(
} }
async function verifyInvoiceAccess( async function verifyInvoiceAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, ctx: ExpenseContext,
invoiceId: string, invoiceId: string,
) { ) {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
@@ -81,7 +87,7 @@ async function verifyInvoiceAccess(
} }
async function resolveExpenseBusinessId( async function resolveExpenseBusinessId(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, ctx: ExpenseContext,
businessId: string | null, businessId: string | null,
invoice?: { businessId: string | null } | null, invoice?: { businessId: string | null } | null,
) { ) {
@@ -98,7 +104,7 @@ async function resolveExpenseBusinessId(
} }
async function getOwnedExpense( async function getOwnedExpense(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, ctx: ExpenseContext,
expenseId: string, expenseId: string,
) { ) {
const expense = await ctx.db.query.expenses.findFirst({ const expense = await ctx.db.query.expenses.findFirst({
+25 -1
View File
@@ -16,6 +16,7 @@ import {
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item"; import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { getRequestOrigin } from "~/lib/app-url";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "~/env"; import { env } from "~/env";
@@ -984,6 +985,7 @@ export const invoicesRouter = createTRPCRouter({
pdfShowLogo: settings?.pdfShowLogo, pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers, pdfShowPageNumbers: settings?.pdfShowPageNumbers,
}, },
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
); );
const buffer = Buffer.from(await pdfBlob.arrayBuffer()); const buffer = Buffer.from(await pdfBlob.arrayBuffer());
@@ -1046,7 +1048,29 @@ export const invoicesRouter = createTRPCRouter({
where: eq(invoices.publicToken, input.token), where: eq(invoices.publicToken, input.token),
with: { with: {
client: true, client: true,
business: true, // Explicit allowlist: this is a publicProcedure — never let
// secret fields (resendApiKey, resendDomain) reach an
// unauthenticated caller via the business relation.
business: {
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
website: true,
taxId: true,
logoStorageKey: true,
logoMimeType: true,
hideNameWithLogo: true,
},
},
items: { items: {
orderBy: (i, { asc }) => [ orderBy: (i, { asc }) => [
asc(i.date), asc(i.date),
+64 -28
View File
@@ -3,6 +3,7 @@ import { and, count, eq, isNull } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { logAuditEvent } from "~/lib/audit-log"; import { logAuditEvent } from "~/lib/audit-log";
import { deleteObject } from "~/lib/object-storage";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
@@ -12,6 +13,8 @@ import {
import { requireAdmin } from "~/server/api/require-admin"; import { requireAdmin } from "~/server/api/require-admin";
import { import {
accounts, accounts,
apiKeys,
auditLog,
users, users,
clients, clients,
businesses, businesses,
@@ -20,10 +23,14 @@ import {
invoicePayments, invoicePayments,
invoiceTemplates, invoiceTemplates,
expenses, expenses,
expenseReceipts,
recurringInvoices, recurringInvoices,
recurringInvoiceItems, recurringInvoiceItems,
sessions,
ssoProviders,
timeEntries, timeEntries,
platformSettings, platformSettings,
verificationTokens,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { import {
colorModeSchema, colorModeSchema,
@@ -703,7 +710,8 @@ export const settingsRouter = createTRPCRouter({
}, },
}); });
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({ const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany(
{
where: eq(recurringInvoices.createdById, userId), where: eq(recurringInvoices.createdById, userId),
with: { with: {
client: { columns: { name: true } }, client: { columns: { name: true } },
@@ -721,7 +729,8 @@ export const settingsRouter = createTRPCRouter({
], ],
}, },
}, },
}); },
);
const userExpenses = await ctx.db.query.expenses.findMany({ const userExpenses = await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, userId), where: eq(expenses.createdById, userId),
@@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && { ...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier, animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}), }),
...(input.user.theme !== undefined && { theme: input.user.theme }), ...(input.user.theme !== undefined && {
theme: input.user.theme,
}),
...(input.user.onboardingCompletedAt !== undefined && { ...(input.user.onboardingCompletedAt !== undefined && {
onboardingCompletedAt: input.user.onboardingCompletedAt, onboardingCompletedAt: input.user.onboardingCompletedAt,
}), }),
@@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: message:
error instanceof Error ? error.message : "Import failed unexpectedly", error instanceof Error
? error.message
: "Import failed unexpectedly",
}); });
} }
}), }),
@@ -1171,44 +1184,67 @@ export const settingsRouter = createTRPCRouter({
}; };
}), }),
// Delete all user data (for account deletion) // Permanently delete the signed-in account and all associated data.
deleteAllData: sessionProcedure deleteAccount: sessionProcedure
.input( .input(
z.object({ z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", { confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", {
message: "You must type 'DELETE ALL DATA' to confirm", message: "You must type 'DELETE MY ACCOUNT' to confirm",
}), }),
}), }),
) )
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
return await ctx.db.transaction(async (tx) => { const [receiptObjects, logoObjects] = await Promise.all([
// Delete in order due to foreign key constraints ctx.db
// 1. Invoice items (cascade should handle this, but being explicit) .select({ storageKey: expenseReceipts.storageKey })
const userInvoiceIds = await tx .from(expenseReceipts)
.select({ id: invoices.id }) .innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.from(invoices) .where(eq(expenses.createdById, userId)),
.where(eq(invoices.createdById, userId)); ctx.db
.select({ storageKey: businesses.logoStorageKey })
.from(businesses)
.where(eq(businesses.createdById, userId)),
]);
if (userInvoiceIds.length > 0) { // Delete uploaded personal data before removing its database pointers. If object
for (const invoice of userInvoiceIds) { // storage is unavailable, the account remains intact so the user can retry.
await Promise.all(
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
storageKey ? [deleteObject(storageKey)] : [],
),
);
await ctx.db.transaction(async (tx) => {
// Dependents without cascading foreign keys must be removed first.
await tx.delete(auditLog).where(eq(auditLog.actorUserId, userId));
await tx await tx
.delete(invoiceItems) .delete(recurringInvoices)
.where(eq(invoiceItems.invoiceId, invoice.id)); .where(eq(recurringInvoices.createdById, userId));
} await tx.delete(expenses).where(eq(expenses.createdById, userId));
} await tx
.delete(invoicePayments)
// 2. Invoices .where(eq(invoicePayments.createdById, userId));
await tx.delete(invoices).where(eq(invoices.createdById, userId)); await tx.delete(invoices).where(eq(invoices.createdById, userId));
await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId));
// 3. Clients await tx
.delete(invoiceTemplates)
.where(eq(invoiceTemplates.createdById, userId));
await tx.delete(clients).where(eq(clients.createdById, userId)); await tx.delete(clients).where(eq(clients.createdById, userId));
// 4. Businesses
await tx.delete(businesses).where(eq(businesses.createdById, userId)); await tx.delete(businesses).where(eq(businesses.createdById, userId));
return { success: true }; // Authentication, access, and verification records.
await tx.delete(apiKeys).where(eq(apiKeys.userId, userId));
await tx.delete(ssoProviders).where(eq(ssoProviders.userId, userId));
await tx.delete(accounts).where(eq(accounts.userId, userId));
await tx.delete(sessions).where(eq(sessions.userId, userId));
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, ctx.session.user.email));
await tx.delete(users).where(eq(users.id, userId));
}); });
return { success: true };
}), }),
}); });
+3 -2
View File
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
computeTrackedHours, computeTrackedHours,
normalizeOptionalId,
resolveBillingDescription, resolveBillingDescription,
type ClockOutOutcome, type ClockOutOutcome,
} from "~/lib/time-clock"; } from "~/lib/time-clock";
@@ -242,7 +243,7 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
} }
const clientId = input.clientId?.trim() || null; const clientId = normalizeOptionalId(input.clientId);
let clientRecord: { defaultHourlyRate: number | null } | null = null; let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ const found = await ctx.db.query.clients.findFirst({
@@ -514,7 +515,7 @@ export const timeEntriesRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createSchema) .input(createSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() || null; const clientId = normalizeOptionalId(input.clientId);
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+9 -6
View File
@@ -318,6 +318,9 @@ export const businesses = createTable(
website: d.varchar({ length: 255 }), website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }), taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }), logoUrl: d.varchar({ length: 500 }),
logoStorageKey: d.varchar({ length: 500 }),
logoMimeType: d.varchar({ length: 100 }),
hideNameWithLogo: d.boolean().default(false).notNull(),
isDefault: d.boolean().default(false), isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup // Email configuration for custom Resend setup
resendApiKey: d.varchar({ length: 255 }), resendApiKey: d.varchar({ length: 255 }),
@@ -609,10 +612,7 @@ export const invoicePayments = createTable(
amount: d.real().notNull(), amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
date: d.timestamp().notNull(), date: d.timestamp().notNull(),
method: d method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
.varchar({ length: 50 })
.notNull()
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@@ -629,7 +629,9 @@ export const invoicePayments = createTable(
], ],
); );
export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({ export const invoicePaymentsRelations = relations(
invoicePayments,
({ one }) => ({
invoice: one(invoices, { invoice: one(invoices, {
fields: [invoicePayments.invoiceId], fields: [invoicePayments.invoiceId],
references: [invoices.id], references: [invoices.id],
@@ -638,7 +640,8 @@ export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) =>
fields: [invoicePayments.createdById], fields: [invoicePayments.createdById],
references: [users.id], references: [users.id],
}), }),
})); }),
);
// ─── Recurring Invoices ─────────────────────────────────────────────────────── // ─── Recurring Invoices ───────────────────────────────────────────────────────