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
```
**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`
- Password: `demo123`
To rotate the credential temporarily, provision a private password:
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
+125 -43
View File
@@ -53,19 +53,21 @@
"framer-motion": "^12.40.0",
"fuse.js": "^7.4.2",
"lucide-react": "^0.525.0",
"next": "^16.2.9",
"next": "^16.2.12",
"pg": "8.21.0",
"react": "^19.2.7",
"react": "^19.2.8",
"react-colorful": "^5.7.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.8",
"react-dropzone": "^14.3.8",
"recharts": "^3.8.1",
"resend": "^4.8.0",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.6.0",
"trpc": "^0.11.3",
"zod": "^3.25.76",
},
"devDependencies": {
@@ -96,8 +98,8 @@
},
"trustedDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp",
"esbuild",
"unrs-resolver",
],
"packages": {
@@ -327,53 +329,57 @@
"@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-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=="],
@@ -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=="],
"@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/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=="],
@@ -821,6 +827,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
@@ -1435,13 +1445,13 @@
"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-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=="],
@@ -1501,7 +1511,7 @@
"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=="],
@@ -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=="],
"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=="],
@@ -1585,6 +1595,8 @@
"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=="],
"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/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/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -1677,6 +1693,10 @@
"@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-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/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=="],
"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=="],
"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/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=="],
"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=="],
"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=="],
"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=="],
"@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=="],
"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/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/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",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1782100000000,
"tag": "0022_expense_business_receipts",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1782200000000,
"tag": "0023_invoice_item_time_entry",
"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:studio": "drizzle-kit studio",
"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: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",
"fuse.js": "^7.4.2",
"lucide-react": "^0.525.0",
"next": "^16.2.9",
"next": "^16.2.12",
"pg": "8.21.0",
"react": "^19.2.7",
"react": "^19.2.8",
"react-colorful": "^5.7.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.8",
"react-dropzone": "^14.3.8",
"recharts": "^3.8.1",
"resend": "^4.8.0",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.6.0",
"trpc": "^0.11.3",
"zod": "^3.25.76"
},
"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 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)
@@ -26,6 +26,27 @@ const idxSequence = journal.entries.map((entry) => entry.idx);
const expectedIdx = journal.entries.map((_, i) => 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;
if (missingFromJournal.length > 0) {
@@ -45,6 +66,16 @@ if (badIdx) {
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) {
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 async function GET(
_request: Request,
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
@@ -16,7 +16,28 @@ export async function GET(
where: eq(invoices.publicToken, token),
with: {
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: {
orderBy: (i, { asc }) => [
asc(i.date),
@@ -39,10 +60,16 @@ export async function GET(
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
@@ -51,7 +78,9 @@ export async function GET(
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
},
{ logoBaseUrl: new URL(request.url).origin },
);
const buffer = await pdfBlob.arrayBuffer();
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
@@ -74,9 +74,20 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<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">
<Building className="text-primary h-5 w-5" />
</div>
)}
<span>Business Information</span>
</CardTitle>
</CardHeader>
@@ -34,6 +34,7 @@ interface Business {
website: string | null;
taxId: string | null;
logoUrl: string | null;
logoStorageKey: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
@@ -86,8 +87,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original;
return (
<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" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium">{business.name}</p>
+10
View File
@@ -377,6 +377,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<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">
{invoice.business.name}
</h3>
@@ -204,9 +204,12 @@ export default function SendEmailPage() {
: undefined,
business: invoiceData.business
? {
id: invoiceData.business.id,
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType,
}
: undefined,
items: invoiceData.items?.map((item) => ({
@@ -162,8 +162,11 @@ export function SettingsContent({
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
useAppearance();
const {
colorMode,
updateAppearance,
isUpdating: appearanceUpdating,
} = useAppearance();
const utils = api.useUtils();
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
@@ -221,8 +224,11 @@ export function SettingsContent({
};
// Queries
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
api.settings.getProfile.useQuery();
const {
data: profile,
refetch: refetchProfile,
isFetched: profileFetched,
} = api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery();
@@ -285,10 +291,13 @@ export function SettingsContent({
},
});
const deleteDataMutation = api.settings.deleteAllData.useMutation({
onSuccess: () => {
toast.success("All data has been permanently deleted");
const deleteAccountMutation = api.settings.deleteAccount.useMutation({
onSuccess: async () => {
toast.success("Your account and data have been permanently deleted");
setDeleteConfirmText("");
await authClient.signOut().catch(() => undefined);
router.replace("/login");
router.refresh();
},
onError: (error: { message: string }) => {
toast.error(`Delete failed: ${error.message}`);
@@ -401,12 +410,12 @@ export function SettingsContent({
}
};
const handleDeleteAllData = () => {
if (deleteConfirmText !== "delete all my data") {
toast.error("Please type 'delete all my data' to confirm");
const handleDeleteAccount = () => {
if (deleteConfirmText !== "DELETE MY ACCOUNT") {
toast.error("Please type 'DELETE MY ACCOUNT' to confirm");
return;
}
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
deleteAccountMutation.mutate({ confirmText: deleteConfirmText });
};
// Set initial name value once when profile loads
@@ -706,10 +715,7 @@ export function SettingsContent({
</SelectTrigger>
<SelectContent>
{colorModes.map((modeOption) => (
<SelectItem
key={modeOption.value}
value={modeOption.value}
>
<SelectItem key={modeOption.value} value={modeOption.value}>
{modeOption.label}
</SelectItem>
))}
@@ -1283,37 +1289,43 @@ export function SettingsContent({
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
Delete All Data
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete
your account and remove your data from our servers.
This action cannot be undone. It permanently deletes your
account, invoices, clients, businesses, expenses, time
entries, uploaded files, and sign-in data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="my-4 space-y-2">
<Label htmlFor="confirm-delete">
Type <span className="font-bold">delete all my data</span>{" "}
to confirm
Type <span className="font-bold">DELETE MY ACCOUNT</span> to
confirm
</Label>
<Input
id="confirm-delete"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete all my data"
placeholder="DELETE MY ACCOUNT"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAllData}
onClick={handleDeleteAccount}
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>
</AlertDialogFooter>
</AlertDialogContent>
+20 -4
View File
@@ -92,6 +92,8 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: null;
const hasLogo = Boolean(invoice.business?.logoStorageKey);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return (
<div className="min-h-screen bg-gray-50 py-10 px-4">
@@ -99,11 +101,25 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Card */}
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
{/* Header */}
<div className="bg-gray-900 px-8 py-6">
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
{invoice.business?.email && (
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p>
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
{hasLogo && (
// Uploaded SVGs are sanitized and served by our route. next/image's
// 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>
{/* Body */}
+2 -14
View File
@@ -8,6 +8,7 @@ import { getAppUrl } from "~/lib/app-url";
import { brand } from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script";
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
import { BrandBackground } from "~/components/layout/brand-background";
const siteTitle = `${brand.name} - Invoicing Made Simple`;
@@ -63,20 +64,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
>
<head>
<script
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 {}
`,
}}
/>
<AppearanceInitScript />
</head>
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<BrandBackground />
+178
View File
@@ -7,12 +7,15 @@ import {
EyeOff,
FileText,
Globe,
ImageIcon,
Info,
Key,
Loader2,
Mail,
Save,
Star,
Trash2,
Upload,
User,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -59,6 +62,7 @@ interface FormData {
country: string;
website: string;
taxId: string;
hideNameWithLogo: boolean;
isDefault: boolean;
resendApiKey: string;
resendDomain: string;
@@ -95,20 +99,31 @@ const initialFormData: FormData = {
country: "United States",
website: "",
taxId: "",
hideNameWithLogo: false,
isDefault: false,
resendApiKey: "",
resendDomain: "",
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) {
const router = useRouter();
const utils = api.useUtils();
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
// Fetch business data if editing
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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
setInitialized(false);
@@ -178,6 +249,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
hideNameWithLogo: business.hideNameWithLogo ?? false,
isDefault: business.isDefault ?? false,
resendApiKey: "", // Never pre-fill API key for security
resendDomain: emailConfig?.resendDomain ?? "",
@@ -338,6 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
isDefault: dataToSubmit.isDefault,
};
@@ -376,6 +449,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
isDefault: dataToSubmit.isDefault,
};
@@ -649,6 +723,110 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</CardContent>
</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 */}
<Card className="bg-card border-border border">
<CardHeader>
+3
View File
@@ -25,8 +25,11 @@ interface EmailPreviewProps {
email: string | null;
};
business?: {
id?: string;
name: string;
email: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
};
items?: Array<{
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 {
LEGAL_PRIVACY_EMAIL,
LEGAL_WEBSITE,
} from "~/lib/legal";
import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
import { brand } from "~/lib/branding";
import {
LegalDocument,
@@ -16,9 +13,9 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
This Privacy Policy explains how {brand.name} collects, uses, and protects
information when you use our invoicing platform, including the web app and mobile
app (the Service).
This Privacy Policy explains how {brand.name} collects, uses, and
protects information when you use our invoicing platform, including
the web app and mobile app (the Service).
</LegalParagraph>
<LegalParagraph>
If you have questions about this policy, email us at{" "}
@@ -33,19 +30,21 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
When you create an account and use the Service, you provide information such as
your name, email address, business details, client records, invoice content, and
time entries. This is the data you enter to run your invoicing workflow.
When you create an account and use the Service, you provide
information such as your name, email address, business details, client
records, invoice content, and time entries. This is the data you enter
to run your invoicing workflow.
</LegalParagraph>
<LegalParagraph>
You may also add payment instructions that appear on invoices, such as bank
transfer details. We do not process card payments on your behalf.
You may also add payment instructions that appear on invoices, such as
bank transfer details. We do not process card payments on your behalf.
</LegalParagraph>
<LegalParagraph>
We also collect some technical information automatically so the Service stays
secure and reliable. This can include your IP address, device and browser or app
details, log and diagnostic data, and session cookies that keep you signed in.
Some deployments may use optional, privacy-focused analytics.
We also collect some technical information automatically so the
Service stays secure and reliable. This can include your IP address,
device and browser or app details, log and diagnostic data, and
session cookies that keep you signed in. Some deployments may use
optional, privacy-focused analytics.
</LegalParagraph>
</>
),
@@ -56,9 +55,10 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
We use your information to provide and operate the Service, authenticate your
account, send transactional messages such as password resets, respond to support
requests, monitor security and performance, and meet legal obligations.
We use your information to provide and operate the Service,
authenticate your account, send transactional messages such as
password resets, respond to support requests, monitor security and
performance, and meet legal obligations.
</LegalParagraph>
</>
),
@@ -69,24 +69,24 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
We do not sell your personal information. We share it only when needed to run the
Service or when the law requires it.
We do not sell your personal information. We share it only when needed
to run the Service or when the law requires it.
</LegalParagraph>
<LegalParagraph>
We work with service providers that host our infrastructure, deliver transactional
email, support single sign-on when enabled on your instance, and optionally provide
privacy-focused analytics. These vendors may process your information only to
perform services for us.
We work with service providers that host our infrastructure, deliver
transactional email, support single sign-on when enabled on your
instance, and optionally provide privacy-focused analytics. These
vendors may process your information only to perform services for us.
</LegalParagraph>
<LegalParagraph>
We may disclose information if we believe it is reasonably necessary to comply with
law, respond to a valid legal request, or protect the security and integrity of the
Service.
We may disclose information if we believe it is reasonably necessary
to comply with law, respond to a valid legal request, or protect the
security and integrity of the Service.
</LegalParagraph>
<LegalParagraph>
If we are involved in a merger, acquisition, or sale of assets, your information
may be transferred as part of that transaction, subject to continued protection
consistent with this policy.
If we are involved in a merger, acquisition, or sale of assets, your
information may be transferred as part of that transaction, subject to
continued protection consistent with this policy.
</LegalParagraph>
</>
),
@@ -97,14 +97,25 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
We use reasonable safeguards to protect information, including encryption in
transit, access controls, and secure authentication. No method of transmission or
storage is completely secure.
We use reasonable safeguards to protect information, including
encryption in transit, access controls, and secure authentication. No
method of transmission or storage is completely secure.
</LegalParagraph>
<LegalParagraph>
We retain information for as long as you have an account or as needed to provide
the Service. We may keep certain records longer when required by law or for
legitimate purposes such as fraud prevention or dispute resolution.
We retain information for as long as you have an account or as needed
to provide the Service. We may keep certain records longer when
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>
</>
),
@@ -115,13 +126,16 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
Depending on where you live, you may have the right to access, correct, delete, or
export your personal information, or to object to or restrict certain processing.
Depending on where you live, you may have the right to access,
correct, delete, or export your personal information, or to object to
or restrict certain processing.
</LegalParagraph>
<LegalParagraph>
To exercise these rights, contact us at{" "}
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will
respond within a reasonable timeframe and as required by applicable law.
You can delete your account directly in Settings. To exercise another
privacy right, or if you cannot access your account, contact us at{" "}
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
We will respond within a reasonable timeframe and as required by
applicable law.
</LegalParagraph>
</>
),
@@ -132,13 +146,13 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
We use cookies and similar technologies to keep you signed in, remember
preferences such as theme, and, when enabled on a deployment, measure usage with
privacy-focused analytics.
We use cookies and similar technologies to keep you signed in,
remember preferences such as theme, and, when enabled on a deployment,
measure usage with privacy-focused analytics.
</LegalParagraph>
<LegalParagraph>
You can control cookies through your browser settings. If you disable essential
cookies, some parts of the Service may not work correctly.
You can control cookies through your browser settings. If you disable
essential cookies, some parts of the Service may not work correctly.
</LegalParagraph>
</>
),
@@ -149,22 +163,26 @@ const sections: LegalSection[] = [
children: (
<>
<LegalParagraph>
The Service may link to third-party websites or integrate with services you
configure, such as single sign-on. Those services have their own privacy policies,
and we are not responsible for their practices.
The Service may link to third-party websites or integrate with
services you configure, such as single sign-on. Those services have
their own privacy policies, and we are not responsible for their
practices.
</LegalParagraph>
<LegalParagraph>
The Service is not intended for children under 13. If you believe a child has
provided us personal information, contact us and we will delete it.
The Service is not intended for children under 13. If you believe a
child has provided us personal information, contact us and we will
delete it.
</LegalParagraph>
<LegalParagraph>
Your information may be processed in countries other than your own. Where required,
we use appropriate safeguards for international transfers.
Your information may be processed in countries other than your own.
Where required, we use appropriate safeguards for international
transfers.
</LegalParagraph>
<LegalParagraph>
We may update this policy from time to time. If we make material changes, we will
post the updated policy on the Service and may notify you by email. Continued use
after changes take effect means you accept the updated policy.
We may update this policy from time to time. If we make material
changes, we will post the updated policy on the Service and may notify
you by email. Continued use after changes take effect means you accept
the updated policy.
</LegalParagraph>
</>
),
@@ -176,7 +194,8 @@ const sections: LegalSection[] = [
<>
<LegalParagraph>
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">
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
</a>
@@ -19,7 +19,13 @@ export function AppearanceProviderSynced({
}: {
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 utils = api.useUtils();
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(() => {
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
@@ -63,15 +63,13 @@ export function AppearanceProvider({
}: {
children: React.ReactNode;
}) {
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
// 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,
);
useEffect(() => {
applyColorMode(colorMode);
+19
View File
@@ -10,6 +10,25 @@ export function getAppUrl(): string {
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). */
export function getAppHost(): string {
try {
+5
View File
@@ -39,6 +39,11 @@ export const auth = betterAuth({
secret: process.env.AUTH_SECRET,
advanced: {
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: {
enabled: true,
+21
View File
@@ -1,5 +1,21 @@
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 {
invoice: {
invoiceNumber: string;
@@ -14,6 +30,7 @@ interface InvoiceEmailTemplateProps {
email: string | null;
};
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
@@ -24,6 +41,8 @@ interface InvoiceEmailTemplateProps {
state?: string | null;
postalCode?: string | null;
country?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
} | null;
items: Array<{
date: Date;
@@ -73,6 +92,7 @@ export function generateInvoiceEmailTemplate({
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
const businessAddress = invoice.business
? [
@@ -409,6 +429,7 @@ export function generateInvoiceEmailTemplate({
<body>
<div class="email-container">
<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-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
</div>
+5 -1
View File
@@ -56,7 +56,11 @@ export const navigationConfig: NavSection[] = [
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Entities", href: "/dashboard/entities", icon: Users },
{ 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: "Reports", href: "/dashboard/reports", icon: BarChart2 },
],
+2 -1
View File
@@ -1,6 +1,7 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
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/.
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
@@ -17,7 +18,7 @@ export function getStorageBackend(): "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 s3Client: InstanceType<S3Module["S3Client"]> | null = null;
+63 -4
View File
@@ -75,6 +75,7 @@ export interface InvoiceData {
currency?: string | null;
notes?: string | null;
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
@@ -87,6 +88,9 @@ export interface InvoiceData {
country?: string | null;
website?: string | null;
taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null;
} | null;
client?: {
name: string;
@@ -843,14 +847,45 @@ function getColumnWidths(showRate: boolean) {
: { 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)
const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
const logoSrc = resolveBusinessLogoSrc(invoice.business, logoBaseUrl);
const hideName = Boolean(logoSrc && invoice.business?.hideNameWithLogo);
return (
<View
@@ -860,6 +895,20 @@ const DenseHeader: React.FC<{
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
>
<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
style={[
styles.businessName,
@@ -869,6 +918,7 @@ const DenseHeader: React.FC<{
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
)}
{invoice.business?.email && (
<Text
style={[
@@ -1390,7 +1440,8 @@ const TotalsSection: React.FC<{
export const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings: inputSettings, logoBaseUrl }) => {
const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily,
@@ -1402,6 +1453,7 @@ export const InvoicePDF: React.FC<{
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
logoBaseUrl={logoBaseUrl}
/>
);
};
@@ -1410,7 +1462,8 @@ const InvoicePDFDocument: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
logoBaseUrl?: string;
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD";
@@ -1426,6 +1479,7 @@ const InvoicePDFDocument: React.FC<{
>
<DenseHeader
invoice={invoice}
logoBaseUrl={logoBaseUrl}
settings={settings}
pdfStyles={pdfStyles}
/>
@@ -1592,6 +1646,7 @@ export async function generateInvoicePDF(
export async function generateInvoicePDFBlob(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
options?: { logoBaseUrl?: string },
): Promise<Blob> {
try {
// Validate invoice data
@@ -1609,7 +1664,11 @@ export async function generateInvoicePDFBlob(
// Generate PDF blob
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
<InvoicePDF
invoice={invoice}
settings={settings}
logoBaseUrl={options?.logoBaseUrl}
/>,
).toBlob();
// 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. */
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(
enteredRate: number,
client?: { defaultHourlyRate?: number | null } | null,
+7 -1
View File
@@ -17,7 +17,13 @@ export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 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
if (apiRoutes.some((route) => pathname.startsWith(route))) {
+134
View File
@@ -1,9 +1,20 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { businesses } from "~/server/db/schema";
import { eq, and, desc } from "drizzle-orm";
import { invoices } from "~/server/db/schema";
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({
name: z
@@ -28,6 +39,7 @@ const businessSchema = z.object({
website: z.string().url().optional().or(z.literal("")),
taxId: z.string().optional().or(z.literal("")),
logoUrl: z.string().optional().or(z.literal("")),
hideNameWithLogo: z.boolean().default(false),
isDefault: z.boolean().default(false),
});
@@ -153,6 +165,7 @@ export const businessesRouter = createTRPCRouter({
input.logoUrl && input.logoUrl.trim() !== ""
? input.logoUrl.trim()
: null,
hideNameWithLogo: input.hideNameWithLogo ?? false,
isDefault: input.isDefault ?? false,
createdById: ctx.session.user.id,
})
@@ -232,6 +245,7 @@ export const businessesRouter = createTRPCRouter({
updateData.logoUrl && updateData.logoUrl.trim() !== ""
? updateData.logoUrl.trim()
: null,
hideNameWithLogo: updateData.hideNameWithLogo ?? false,
isDefault: updateData.isDefault ?? false,
updatedAt: new Date(),
})
@@ -417,4 +431,124 @@ export const businessesRouter = createTRPCRouter({
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 { env } from "~/env";
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 { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -88,7 +88,9 @@ export const emailRouter = createTRPCRouter({
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
@@ -107,7 +109,9 @@ export const emailRouter = createTRPCRouter({
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
},
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
@@ -165,7 +169,7 @@ export const emailRouter = createTRPCRouter({
customMessage,
userName,
userEmail,
baseUrl: getAppUrl(),
baseUrl: getRequestOrigin(ctx.headers),
});
// Determine Resend instance and email configuration to use
+10 -4
View File
@@ -20,9 +20,15 @@ import {
RECEIPT_MAX_BYTES,
} from "~/lib/object-storage";
import { parseReceiptText } from "~/lib/receipt-parse";
import type { db } from "~/server/db";
export { EXPENSE_CATEGORIES };
type ExpenseContext = {
db: typeof db;
session: { user: { id: string } };
};
const createExpenseSchema = z.object({
date: z.date(),
description: z.string().min(1, "Description is required"),
@@ -43,7 +49,7 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
});
async function verifyClientAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
ctx: ExpenseContext,
clientId: string,
) {
const client = await ctx.db.query.clients.findFirst({
@@ -62,7 +68,7 @@ async function verifyClientAccess(
}
async function verifyInvoiceAccess(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
ctx: ExpenseContext,
invoiceId: string,
) {
const invoice = await ctx.db.query.invoices.findFirst({
@@ -81,7 +87,7 @@ async function verifyInvoiceAccess(
}
async function resolveExpenseBusinessId(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
ctx: ExpenseContext,
businessId: string | null,
invoice?: { businessId: string | null } | null,
) {
@@ -98,7 +104,7 @@ async function resolveExpenseBusinessId(
}
async function getOwnedExpense(
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
ctx: ExpenseContext,
expenseId: string,
) {
const expense = await ctx.db.query.expenses.findFirst({
+25 -1
View File
@@ -16,6 +16,7 @@ import {
import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { getRequestOrigin } from "~/lib/app-url";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend";
import { env } from "~/env";
@@ -984,6 +985,7 @@ export const invoicesRouter = createTRPCRouter({
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
);
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
@@ -1046,7 +1048,29 @@ export const invoicesRouter = createTRPCRouter({
where: eq(invoices.publicToken, input.token),
with: {
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: {
orderBy: (i, { asc }) => [
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 bcrypt from "bcryptjs";
import { logAuditEvent } from "~/lib/audit-log";
import { deleteObject } from "~/lib/object-storage";
import {
createTRPCRouter,
protectedProcedure,
@@ -12,6 +13,8 @@ import {
import { requireAdmin } from "~/server/api/require-admin";
import {
accounts,
apiKeys,
auditLog,
users,
clients,
businesses,
@@ -20,10 +23,14 @@ import {
invoicePayments,
invoiceTemplates,
expenses,
expenseReceipts,
recurringInvoices,
recurringInvoiceItems,
sessions,
ssoProviders,
timeEntries,
platformSettings,
verificationTokens,
} from "~/server/db/schema";
import {
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),
with: {
client: { columns: { name: true } },
@@ -721,7 +729,8 @@ export const settingsRouter = createTRPCRouter({
],
},
},
});
},
);
const userExpenses = await ctx.db.query.expenses.findMany({
where: eq(expenses.createdById, userId),
@@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}),
...(input.user.theme !== undefined && { theme: input.user.theme }),
...(input.user.theme !== undefined && {
theme: input.user.theme,
}),
...(input.user.onboardingCompletedAt !== undefined && {
onboardingCompletedAt: input.user.onboardingCompletedAt,
}),
@@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
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)
deleteAllData: sessionProcedure
// Permanently delete the signed-in account and all associated data.
deleteAccount: sessionProcedure
.input(
z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
message: "You must type 'DELETE ALL DATA' to confirm",
confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", {
message: "You must type 'DELETE MY ACCOUNT' to confirm",
}),
}),
)
.mutation(async ({ ctx }) => {
const userId = ctx.session.user.id;
return await ctx.db.transaction(async (tx) => {
// Delete in order due to foreign key constraints
// 1. Invoice items (cascade should handle this, but being explicit)
const userInvoiceIds = await tx
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.createdById, userId));
const [receiptObjects, logoObjects] = await Promise.all([
ctx.db
.select({ storageKey: expenseReceipts.storageKey })
.from(expenseReceipts)
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.where(eq(expenses.createdById, userId)),
ctx.db
.select({ storageKey: businesses.logoStorageKey })
.from(businesses)
.where(eq(businesses.createdById, userId)),
]);
if (userInvoiceIds.length > 0) {
for (const invoice of userInvoiceIds) {
// Delete uploaded personal data before removing its database pointers. If object
// 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
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
}
}
// 2. Invoices
.delete(recurringInvoices)
.where(eq(recurringInvoices.createdById, userId));
await tx.delete(expenses).where(eq(expenses.createdById, userId));
await tx
.delete(invoicePayments)
.where(eq(invoicePayments.createdById, userId));
await tx.delete(invoices).where(eq(invoices.createdById, userId));
// 3. Clients
await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId));
await tx
.delete(invoiceTemplates)
.where(eq(invoiceTemplates.createdById, userId));
await tx.delete(clients).where(eq(clients.createdById, userId));
// 4. Businesses
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 {
computeTrackedHours,
normalizeOptionalId,
resolveBillingDescription,
type ClockOutOutcome,
} 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;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
@@ -514,7 +515,7 @@ export const timeEntriesRouter = createTRPCRouter({
create: protectedProcedure
.input(createSchema)
.mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() || null;
const clientId = normalizeOptionalId(input.clientId);
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
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 }),
taxId: d.varchar({ length: 100 }),
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),
// Email configuration for custom Resend setup
resendApiKey: d.varchar({ length: 255 }),
@@ -609,10 +612,7 @@ export const invoicePayments = createTable(
amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
date: d.timestamp().notNull(),
method: d
.varchar({ length: 50 })
.notNull()
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
notes: d.varchar({ length: 500 }),
createdById: d
.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, {
fields: [invoicePayments.invoiceId],
references: [invoices.id],
@@ -638,7 +640,8 @@ export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) =>
fields: [invoicePayments.createdById],
references: [users.id],
}),
}));
}),
);
// ─── Recurring Invoices ───────────────────────────────────────────────────────