From e4979d56f09bed4890f68e16b363efeaa7873e0e Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 01:13:02 +0200 Subject: [PATCH 01/21] feat: add and setup better-auth to backend --- backend/.env.example | 6 + backend/drizzle.config.ts | 2 +- backend/package.json | 2 + backend/pnpm-lock.yaml | 309 +++++++++++++++++- backend/src/auth/index.ts | 15 + backend/src/db/index.ts | 7 +- backend/src/db/schema/auth.ts | 130 ++++++++ backend/src/db/{schema.ts => schema/books.ts} | 0 backend/src/db/schema/index.ts | 2 + backend/src/db/seed/index.ts | 2 +- backend/src/index.ts | 12 + .../books/resolvers/Query/searchBooks.ts | 2 +- 12 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 backend/src/auth/index.ts create mode 100644 backend/src/db/schema/auth.ts rename backend/src/db/{schema.ts => schema/books.ts} (100%) create mode 100644 backend/src/db/schema/index.ts diff --git a/backend/.env.example b/backend/.env.example index 7938570..2330c3e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,7 @@ DATABASE_URL=postgresql://postgres@localhost:5433/readr +BETTER_AUTH_URL=http://localhost:3001 +FRONTEND_URL=http://localhost:5173 + +# Add a random string here +# In order to generate one, you may use this command: openssl rand -base64 32 +BETTER_AUTH_SECRET= diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index bb9afba..2faf7be 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ out: "./drizzle", - schema: "./src/db/schema.ts", + schema: "./src/db/schema/index.ts", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, diff --git a/backend/package.json b/backend/package.json index 6cf159e..53edd80 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,9 @@ "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", + "@better-auth/drizzle-adapter": "^1.7.7", "@graphql-tools/merge": "^9.2.6", + "better-auth": "^1.7.7", "cors": "^2.8.6", "csv-parse": "^7.0.3", "dotenv": "^18.0.5", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 58c4a04..4affcff 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -172,9 +172,15 @@ importers: '@as-integrations/express5': specifier: ^1.1.2 version: 1.1.2(@apollo/server@5.5.1(graphql@17.0.2))(express@5.2.1) + '@better-auth/drizzle-adapter': + specifier: ^1.7.7 + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6)) '@graphql-tools/merge': specifier: ^9.2.6 version: 9.2.6(graphql@17.0.2) + better-auth: + specifier: ^1.7.7 + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))(pg@8.23.1) cors: specifier: ^2.8.6 version: 2.8.6 @@ -186,7 +192,7 @@ importers: version: 18.0.5 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1) + version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) express: specifier: ^5.2.1 version: 5.2.1 @@ -402,6 +408,85 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.7.7': + resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.7': + resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.7': + resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.7': + resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7': + resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.7': + resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.7': + resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@drizzle-team/brocli@0.12.1': resolution: {integrity: sha512-yHwomvolVafvUXhy5TdiJVkuNCxXn+bKOs2aWiWEQh/5YaV97Oa/abJz3dmx7lxgIUsUlS1Jl4mEfvJPo3YsVw==} @@ -1142,6 +1227,14 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1154,6 +1247,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1187,6 +1284,9 @@ packages: '@repeaterjs/repeater@3.1.0': resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@ts-morph/common@0.23.0': resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} @@ -1430,6 +1530,73 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.7.7: + resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1585,6 +1752,9 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2062,6 +2232,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2089,6 +2262,10 @@ packages: engines: {node: '>=6'} hasBin: true + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2190,6 +2367,10 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} + negotiator@1.1.0: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} @@ -2388,6 +2569,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2413,6 +2597,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.3: + resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2640,6 +2827,9 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} + snapshots: '@apollo/cache-control-types@1.0.3(graphql@17.0.2)': @@ -2864,6 +3054,63 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.6.6) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.6 + + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) + + '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.6 + + '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + '@drizzle-team/brocli@0.12.1': {} '@eddeee888/gcg-server-config@0.6.0(graphql@17.0.2)': @@ -3616,6 +3863,10 @@ snapshots: dependencies: jsbi: 4.3.2 + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3628,6 +3879,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@opentelemetry/semantic-conventions@1.43.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -3652,6 +3905,8 @@ snapshots: '@repeaterjs/repeater@3.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@ts-morph/common@0.23.0': dependencies: fast-glob: 3.3.3 @@ -3851,6 +4106,41 @@ snapshots: baseline-browser-mapping@2.11.27: {} + better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))(pg@8.23.1): + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6)) + '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.6.6) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.6 + optionalDependencies: + drizzle-kit: 1.0.0-rc.4 + drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) + pg: 8.23.1 + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.6.6): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.3 + optionalDependencies: + zod: 4.6.6 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -3999,6 +4289,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + defu@6.1.7: {} + depd@2.0.0: {} dependency-graph@1.0.0: {} @@ -4019,10 +4311,11 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1): + drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6): optionalDependencies: '@types/pg': 8.23.1 pg: 8.23.1 + zod: 4.6.6 dunder-proto@1.0.1: dependencies: @@ -4386,6 +4679,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -4405,6 +4700,8 @@ snapshots: json5@2.2.3: {} + kysely@0.29.6: {} + lines-and-columns@1.2.4: {} listr2@10.2.2: @@ -4485,6 +4782,8 @@ snapshots: mute-stream@3.0.0: {} + nanostores@1.5.5: {} + negotiator@1.1.0: dependencies: content-type: 2.1.0 @@ -4651,6 +4950,8 @@ snapshots: rfdc@1.4.1: {} + rou3@0.9.2: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -4696,6 +4997,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.3: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -4929,3 +5232,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors@2.2.0: {} + + zod@4.6.6: {} diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts new file mode 100644 index 0000000..d7b263c --- /dev/null +++ b/backend/src/auth/index.ts @@ -0,0 +1,15 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +import { db } from "~/db"; +import * as schema from "~/db/schema"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + emailAndPassword: { + enabled: true, + }, + trustedOrigins: [process.env.FRONTEND_URL ?? "http://localhost:5173"], +}); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index eaf3a39..e6c8fa0 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,7 +1,10 @@ import "dotenv/config"; import { drizzle } from "drizzle-orm/node-postgres"; -import { relations } from "~/db/schema"; +import { authRelations } from "~/db/schema/auth"; +import { relations } from "~/db/schema/books"; -const db = drizzle(process.env.DATABASE_URL!, { relations }); +const db = drizzle(process.env.DATABASE_URL!, { + relations: { ...relations, ...authRelations }, +}); export { db }; diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts new file mode 100644 index 0000000..935a014 --- /dev/null +++ b/backend/src/db/schema/auth.ts @@ -0,0 +1,130 @@ +import { defineRelationsPart } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; + +const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +const authRelations = defineRelationsPart( + { user, session, account, verification }, + (r) => ({ + user: { + sessions: r.many.session({ + from: r.user.id, + to: r.session.userId, + }), + accounts: r.many.account({ + from: r.user.id, + to: r.account.userId, + }), + }, + session: { + user: r.one.user({ + from: r.session.userId, + to: r.user.id, + }), + }, + account: { + user: r.one.user({ + from: r.account.userId, + to: r.user.id, + }), + }, + }), +); + +type SelectUser = typeof user.$inferSelect; +type InsertUser = typeof user.$inferInsert; + +type SelectSession = typeof session.$inferSelect; +type InsertSession = typeof session.$inferInsert; + +type SelectAccount = typeof account.$inferSelect; +type InsertAccount = typeof account.$inferInsert; + +type SelectVerification = typeof verification.$inferSelect; +type InsertVerification = typeof verification.$inferInsert; + +export { + user, + session, + account, + verification, + authRelations, + type SelectUser, + type InsertUser, + type SelectSession, + type InsertSession, + type SelectAccount, + type InsertAccount, + type SelectVerification, + type InsertVerification, +}; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema/books.ts similarity index 100% rename from backend/src/db/schema.ts rename to backend/src/db/schema/books.ts diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts new file mode 100644 index 0000000..f48a322 --- /dev/null +++ b/backend/src/db/schema/index.ts @@ -0,0 +1,2 @@ +export * from "./auth"; +export * from "./books"; diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 3459d6e..a095323 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -3,7 +3,7 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "csv-parse"; import { db } from "~/db"; -import { books, type InsertBook } from "~/db/schema"; +import { books, type InsertBook } from "~/db/schema/books"; type BookRow = { bookId: string; diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d6e6ea..d72b7b3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,8 @@ import cors from "cors"; import { expressMiddleware } from "@as-integrations/express5"; import { typeDefs } from "~/schema/typeDefs.generated"; import { resolvers } from "~/schema/resolvers.generated"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "~/auth"; type ApolloContext = { token?: string; @@ -29,6 +31,16 @@ const server = new ApolloServer({ await server.start(); +app.all( + "/api/auth/{*any}", + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", + methods: ["GET", "POST", "PUT", "DELETE"], + credentials: true, + }), + toNodeHandler(auth), +); + app.use( "/", cors(), diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 03ed08c..afc7435 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,6 +1,6 @@ import { and, asc, ilike, type SQL } from "drizzle-orm"; import { db } from "~/db"; -import { books } from "~/db/schema"; +import { books } from "~/db/schema/books"; import type { QueryResolvers } from "~/schema/types.generated"; export const searchBooks: NonNullable = async ( From eccf9c09fa5d617f558a728003bf432527dbb854 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 01:13:34 +0200 Subject: [PATCH 02/21] feat: add better-auth client to frontend --- frontend/app/api/auth.ts | 7 + frontend/app/api/types/graphql.generated.ts | 35 +-- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 329 ++++++++++++++++++-- 4 files changed, 323 insertions(+), 49 deletions(-) create mode 100644 frontend/app/api/auth.ts diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts new file mode 100644 index 0000000..47fd7c4 --- /dev/null +++ b/frontend/app/api/auth.ts @@ -0,0 +1,7 @@ +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3001", +}); + +export { authClient }; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 49a2f65..ae4a7af 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -1,26 +1,13 @@ /** Internal type. DO NOT USE DIRECTLY. */ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ -export type Incremental = - | T - | { - [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never; - }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; export type BookQueryVariables = Exact<{ id: number; }>; -export type BookQuery = { - book: { - __typename: "Book"; - id: number; - title: string; - author: string; - publishDate: string | null; - coverImgUrl: string | null; - description: string | null; - } | null; -}; + +export type BookQuery = { book: { __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, description: string | null } | null }; export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; @@ -28,17 +15,5 @@ export type SearchBooksQueryVariables = Exact<{ offset?: number | null | undefined; }>; -export type SearchBooksQuery = { - searchBooks: { - __typename: "BookSearchResult"; - totalCount: number; - books: Array<{ - __typename: "Book"; - id: number; - title: string; - author: string; - publishDate: string | null; - coverImgUrl: string | null; - }>; - }; -}; + +export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null }> } }; diff --git a/frontend/package.json b/frontend/package.json index d48194f..5b2eb5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@react-router/node": "^8.4.0", "@react-router/serve": "^8.4.0", "@tailwindcss/vite": "^4.3.3", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "graphql": "^17.0.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fabcb31..08c9c9d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + better-auth: + specifier: ^1.7.7 + version: 1.7.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -89,7 +92,7 @@ importers: version: 10.4.2 '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))) + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -128,7 +131,7 @@ importers: version: 17.13.0 jsdom: specifier: ^30.1.1 - version: 30.1.2 + version: 30.1.2(@noble/hashes@2.4.0) prettier: specifier: 3.9.9 version: 3.9.9 @@ -143,7 +146,7 @@ importers: version: 8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) vitest: specifier: ^5.0.2 - version: 5.0.3(@types/node@24.19.1)(jsdom@30.1.2)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -345,6 +348,85 @@ packages: '@types/react': optional: true + '@better-auth/core@1.7.7': + resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.7': + resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.7': + resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.7': + resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7': + resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.7': + resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.7': + resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@bramus/specificity@2.4.3': resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} hasBin: true @@ -976,6 +1058,14 @@ packages: '@cfworker/json-schema': optional: true + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -988,6 +1078,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} @@ -1179,6 +1273,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1594,6 +1691,73 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + better-auth@1.7.7: + resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} @@ -1875,6 +2039,9 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2623,6 +2790,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2924,6 +3095,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3258,6 +3433,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3305,6 +3483,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.3: + resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -4081,6 +4262,61 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.6.5) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.5 + + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.6 + + '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + '@bramus/specificity@2.4.3': dependencies: css-tree: 3.2.1 @@ -4284,7 +4520,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exodus/bytes@1.16.0': {} + '@exodus/bytes@1.16.0(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 '@fastify/busboy@3.2.2': {} @@ -4884,6 +5122,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4896,6 +5138,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.152.0': {} '@react-router/dev@8.4.0(@react-router/serve@8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3))(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))': @@ -5055,6 +5299,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -5134,7 +5380,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.2 @@ -5144,7 +5390,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.3(@types/node@24.19.1)(jsdom@30.1.2)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -5476,6 +5722,41 @@ snapshots: dependencies: safe-buffer: 5.1.2 + better-auth@1.7.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))): + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.6.5) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.5 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + vitest: 5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.6.5): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.3 + optionalDependencies: + zod: 4.6.5 + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 @@ -5690,10 +5971,10 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@8.0.0: + data-urls@8.0.0(@noble/hashes@2.4.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 17.2.0 + whatwg-url: 17.2.0(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -5732,6 +6013,8 @@ snapshots: define-lazy-prop@3.0.0: {} + defu@6.1.7: {} + depd@2.0.0: {} dependency-graph@1.0.0: {} @@ -6295,9 +6578,9 @@ snapshots: hookified@2.2.0: {} - html-encoding-sniffer@7.0.0: + html-encoding-sniffer@7.0.0(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.16.0 + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -6437,17 +6720,17 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@30.1.2: + jsdom@30.1.2(@noble/hashes@2.4.0): dependencies: '@asamuzakjp/css-color': 7.1.3 '@asamuzakjp/dom-selector': 9.2.4 '@bramus/specificity': 2.4.3 '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) - '@exodus/bytes': 1.16.0 + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) css-tree: 3.2.1 - data-urls: 8.0.0 + data-urls: 8.0.0(@noble/hashes@2.4.0) decimal.js: 10.6.0 - html-encoding-sniffer: 7.0.0 + html-encoding-sniffer: 7.0.0(@noble/hashes@2.4.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 parse5: 8.0.1 @@ -6457,7 +6740,7 @@ snapshots: w3c-xmlserializer: 6.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 17.2.0 + whatwg-url: 17.2.0(@noble/hashes@2.4.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -6497,6 +6780,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.29.6: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6726,6 +7011,8 @@ snapshots: nanoid@3.3.20: {} + nanostores@1.5.5: {} + natural-compare@1.4.0: {} negotiator@0.6.4: {} @@ -7048,6 +7335,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.12 '@rolldown/binding-win32-x64-msvc': 1.2.12 + rou3@0.9.2: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -7109,6 +7398,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.3: {} + setprototypeof@1.2.0: {} shadcn@4.21.3: @@ -7424,7 +7715,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2)(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) @@ -7442,7 +7733,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.19.1 - jsdom: 30.1.2 + jsdom: 30.1.2(@noble/hashes@2.4.0) transitivePeerDependencies: - msw @@ -7456,9 +7747,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@17.2.0: + whatwg-url@17.2.0(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.16.0 + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) tr46: 7.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: From a27cae67eefc00847c96a44edbd19b09bdba5f50 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 01:58:26 +0200 Subject: [PATCH 03/21] build: add Tanstack form and zod for form validation --- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 131 +++++++++++++++++++++++++++++----------- 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 5b2eb5e..07db717 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@react-router/node": "^8.4.0", "@react-router/serve": "^8.4.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-form": "^1.33.5", "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", @@ -37,7 +38,8 @@ "rxjs": "^7.8.2", "shadcn": "^4.21.1", "tailwindcss": "^4.3.3", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.6.6" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 08c9c9d..3c76004 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/react-form': + specifier: ^1.33.5 + version: 1.33.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0) better-auth: specifier: ^1.7.7 version: 1.7.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))) @@ -65,6 +68,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.6.6 + version: 4.6.6 devDependencies: '@babel/core': specifier: ^7.29.7 @@ -1370,6 +1376,36 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/form-core@1.33.5': + resolution: {integrity: sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==} + + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + + '@tanstack/react-form@1.33.5': + resolution: {integrity: sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -4004,8 +4040,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} snapshots: @@ -4262,48 +4298,48 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.6.5) + better-call: 1.4.0(zod@4.6.6) jose: 6.2.12 kysely: 0.29.6 nanostores: 1.5.5 - zod: 4.6.5 + zod: 4.6.6 - '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.6 - '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': + '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 @@ -4473,7 +4509,7 @@ snapshots: '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) ts-pattern: 5.9.0 typescript: 6.0.3 - zod: 4.6.5 + zod: 4.6.6 optionalDependencies: eslint: 10.12.0(jiti@2.7.0) transitivePeerDependencies: @@ -5369,6 +5405,33 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/form-core@1.33.5': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.11.2 + + '@tanstack/pacer-lite@0.1.1': {} + + '@tanstack/react-form@1.33.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/form-core': 1.33.5 + '@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + + '@tanstack/store@0.11.2': {} + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -5724,23 +5787,23 @@ snapshots: better-auth@1.7.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.3(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.3(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))): dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) - '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.2 '@noble/ciphers': 2.4.0 '@noble/hashes': 2.4.0 - better-call: 1.4.0(zod@4.6.5) + better-call: 1.4.0(zod@4.6.6) defu: 6.1.7 jose: 6.2.12 kysely: 0.29.6 nanostores: 1.5.5 - zod: 4.6.5 + zod: 4.6.6 optionalDependencies: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -5748,14 +5811,14 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - better-call@1.4.0(zod@4.6.5): + better-call@1.4.0(zod@4.6.6): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.2 rou3: 0.9.2 set-cookie-parser: 3.1.3 optionalDependencies: - zod: 4.6.5 + zod: 4.6.6 bidi-js@1.1.0: dependencies: @@ -6116,8 +6179,8 @@ snapshots: '@babel/parser': 7.29.9 eslint: 10.12.0(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.6.5 - zod-validation-error: 4.0.2(zod@4.6.5) + zod: 4.6.6 + zod-validation-error: 4.0.2(zod@4.6.6) transitivePeerDependencies: - supports-color @@ -7820,10 +7883,10 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.6.5): + zod-validation-error@4.0.2(zod@4.6.6): dependencies: - zod: 4.6.5 + zod: 4.6.6 zod@3.25.76: {} - zod@4.6.5: {} + zod@4.6.6: {} From dffc38a5d55dab8fc4797a69e54b7e287508cf60 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 02:02:02 +0200 Subject: [PATCH 04/21] feat: add sign-up route --- frontend/app/api/auth.ts | 2 +- frontend/app/routes.ts | 1 + frontend/app/routes/signup.tsx | 243 +++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 frontend/app/routes/signup.tsx diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts index 47fd7c4..6df54a9 100644 --- a/frontend/app/api/auth.ts +++ b/frontend/app/api/auth.ts @@ -1,4 +1,4 @@ -import { createAuthClient } from "better-auth/client"; +import { createAuthClient } from "better-auth/react"; const authClient = createAuthClient({ baseURL: "http://localhost:3001", diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 3dc6530..ad5c6bf 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -3,4 +3,5 @@ import { index, route, type RouteConfig } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("book/:bookId", "routes/book/selectedBook.tsx"), + route("signup", "routes/signup.tsx"), ] satisfies RouteConfig; diff --git a/frontend/app/routes/signup.tsx b/frontend/app/routes/signup.tsx new file mode 100644 index 0000000..3103002 --- /dev/null +++ b/frontend/app/routes/signup.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { useForm } from "@tanstack/react-form"; +import { z } from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { toast } from "~/components/ui/Toast"; + +// Matches better-auth's default minPasswordLength. +const MIN_PASSWORD_LENGTH = 8; + +const formSchema = z.object({ + name: z.string().min(1, "Enter your name."), + email: z.email("Enter a valid email address."), + password: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`, + ), + confirmPassword: z.string().min(1, "Confirm your password."), +}); + +export function meta() { + return [{ title: "Sign up | Readr" }]; +} + +export default function SignupPage() { + const navigate = useNavigate(); + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + validators: { + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error } = await authClient.signUp.email({ + name: value.name, + email: value.email, + password: value.password, + }); + + if (error) { + setServerError( + error.message ?? "Could not create account. Please try again.", + ); + return; + } + + toast.add({ + description: `Welcome to Readr, ${value.name}! Your account has been created successfully.`, + type: "success", + }); + + await navigate("/"); + }, + }); + + return ( +
+ + + +

Sign up

+
+ Create an account to get started. +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Name + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="name" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Email + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="email" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Password + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + aria-describedby="password-description" + autoComplete="new-password" + /> + + At least {MIN_PASSWORD_LENGTH} characters. + + {isInvalid && ( + + )} + + ); + }} + + { + if (value !== fieldApi.form.getFieldValue("password")) { + return { message: "Passwords do not match." }; + } + return undefined; + }, + }} + > + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Confirm password + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="new-password" + /> + {isInvalid && ( + + )} + + ); + }} + + {serverError} + +
+
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +

+ Already have an account?{" "} + + Log in + +

+
+
+
+ ); +} From 6401867a42ed87a53aec5b2762abdf7a90a1c719 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 02:02:22 +0200 Subject: [PATCH 05/21] feat: add login route --- frontend/app/routes.ts | 1 + frontend/app/routes/login.tsx | 162 ++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 frontend/app/routes/login.tsx diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index ad5c6bf..e996351 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -4,4 +4,5 @@ export default [ index("routes/home.tsx"), route("book/:bookId", "routes/book/selectedBook.tsx"), route("signup", "routes/signup.tsx"), + route("login", "routes/login.tsx"), ] satisfies RouteConfig; diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx new file mode 100644 index 0000000..147ca44 --- /dev/null +++ b/frontend/app/routes/login.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { useForm } from "@tanstack/react-form"; +import { z } from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { toast } from "~/components/ui/Toast"; + +const formSchema = z.object({ + email: z.email("Enter a valid email address."), + password: z.string().min(1, "Enter your password."), +}); + +export function meta() { + return [{ title: "Log in | Readr" }]; +} + +export default function LoginPage() { + const navigate = useNavigate(); + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + email: "", + password: "", + }, + validators: { + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error, data } = await authClient.signIn.email(value); + + if (error) { + setServerError(error.message ?? "Could not log in. Please try again."); + return; + } + + toast.add({ + description: `Welcome, ${data.user.name}! You've signed in successfully.`, + type: "success", + }); + + await navigate("/"); + }, + }); + + return ( +
+ + + +

Log in

+
+ + Enter your email and password to log in. + +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Email + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="email" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Password + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="current-password" + /> + {isInvalid && ( + + )} + + ); + }} + + {serverError} + +
+
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} From 443d5dd01031a28675b7e17bf4fc03b1d414381b Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 02:02:29 +0200 Subject: [PATCH 06/21] feat: show user in Header --- frontend/app/components/Header/Header.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/app/components/Header/Header.tsx b/frontend/app/components/Header/Header.tsx index e1d6d66..4f15604 100644 --- a/frontend/app/components/Header/Header.tsx +++ b/frontend/app/components/Header/Header.tsx @@ -1,7 +1,25 @@ +import { Link } from "react-router"; +import { authClient } from "~/api/auth"; +import { Button, buttonVariants } from "~/components/ui/Button"; + function Header() { + const { data: session, isPending } = authClient.useSession(); + return (

Readr

+ {!isPending && session ? ( +
+

{session.user.name}

+ +
+ ) : ( + + Log in + + )}
); } From a97d527c79dddb9673dddf8d420c8b47d32a1613 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 22:08:03 +0200 Subject: [PATCH 07/21] feat: adjust ApolloContext and CORS to prepare for authenticated GraphQL requests --- backend/codegen.ts | 5 ++++- backend/src/index.ts | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/codegen.ts b/backend/codegen.ts index cd59d61..1ffe5c6 100644 --- a/backend/codegen.ts +++ b/backend/codegen.ts @@ -6,7 +6,10 @@ const config: CodegenConfig = { schema: "**/schema.graphql", generates: { "src/schema": defineConfig({ - typesPluginsConfig: { useTypeImports: true }, + typesPluginsConfig: { + useTypeImports: true, + contextType: "~/index#ApolloContext", + }, }), }, }; diff --git a/backend/src/index.ts b/backend/src/index.ts index d72b7b3..5621a39 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,11 +6,11 @@ import cors from "cors"; import { expressMiddleware } from "@as-integrations/express5"; import { typeDefs } from "~/schema/typeDefs.generated"; import { resolvers } from "~/schema/resolvers.generated"; -import { toNodeHandler } from "better-auth/node"; +import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; import { auth } from "~/auth"; -type ApolloContext = { - token?: string; +export type ApolloContext = { + session: Awaited>; }; const PORT = 3001; @@ -19,7 +19,7 @@ const PORT = 3001; // https://www.apollographql.com/docs/apollo-server/getting-started // https://www.apollographql.com/docs/apollo-server/api/standalone#swapping-to-expressmiddleware // https://www.apollographql.com/docs/apollo-server/api/express-middleware -const app: Express = express(); +const app = express(); const httpServer = http.createServer(app); @@ -43,10 +43,17 @@ app.all( app.use( "/", - cors(), + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", + credentials: true, + }), express.json(), expressMiddleware(server, { - context: async ({ req }) => ({ token: req.headers.token }), + context: async ({ req }) => ({ + session: await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }), + }), }), ); From b507f2d9618cb35abd6662c56e6cc0e7f055fda4 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 22:09:05 +0200 Subject: [PATCH 08/21] feat: add additional bio field to user table --- backend/src/auth/index.ts | 8 ++++++++ backend/src/db/schema/auth.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts index d7b263c..2662ae9 100644 --- a/backend/src/auth/index.ts +++ b/backend/src/auth/index.ts @@ -11,5 +11,13 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + user: { + additionalFields: { + bio: { + type: "string", + required: false, + }, + }, + }, trustedOrigins: [process.env.FRONTEND_URL ?? "http://localhost:5173"], }); diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts index 935a014..38acef1 100644 --- a/backend/src/db/schema/auth.ts +++ b/backend/src/db/schema/auth.ts @@ -7,6 +7,7 @@ const user = pgTable("user", { email: text("email").notNull().unique(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), + bio: text("bio"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() From 522ef1772eb8589922d4f9480fd691a785bb54d2 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 22:11:24 +0200 Subject: [PATCH 09/21] feat: add favoriteBooks schema and move relations Relations are now moved to db/index.ts file in order to avoid circular dependencies, and define relations in one place to keep them easy to sync --- backend/src/db/index.ts | 81 ++++++++++++++++++++++++++++-- backend/src/db/schema/auth.ts | 30 ----------- backend/src/db/schema/books.ts | 14 ------ backend/src/db/schema/favorites.ts | 28 +++++++++++ backend/src/db/schema/index.ts | 1 + 5 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 backend/src/db/schema/favorites.ts diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index e6c8fa0..f019bb8 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,10 +1,81 @@ import "dotenv/config"; +import { defineRelations } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; -import { authRelations } from "~/db/schema/auth"; -import { relations } from "~/db/schema/books"; +import { + account, + bookGenres, + books, + bookToGenres, + favoriteBooks, + session, + user, + verification, +} from "~/db/schema"; -const db = drizzle(process.env.DATABASE_URL!, { - relations: { ...relations, ...authRelations }, -}); +// We define all the relations in one place so the individual schema files don't have to import each other, which makes it easy to create circular dependencies. +const relations = defineRelations( + { + user, + session, + account, + verification, + favoriteBooks, + books, + bookGenres, + bookToGenres, + }, + (r) => ({ + // Auth + user: { + sessions: r.many.session({ + from: r.user.id, + to: r.session.userId, + }), + accounts: r.many.account({ + from: r.user.id, + to: r.account.userId, + }), + favoriteBooks: r.many.favoriteBooks({ + from: r.user.id, + to: r.favoriteBooks.userId, + }), + }, + session: { + user: r.one.user({ + from: r.session.userId, + to: r.user.id, + }), + }, + account: { + user: r.one.user({ + from: r.account.userId, + to: r.user.id, + }), + }, + // Books + books: { + genres: r.many.bookGenres({ + from: r.books.id.through(r.bookToGenres.bookId), + to: r.bookGenres.id.through(r.bookToGenres.genreId), + }), + }, + bookGenres: { + books: r.many.books(), + }, + // Favorites + favoriteBooks: { + user: r.one.user({ + from: r.favoriteBooks.userId, + to: r.user.id, + }), + book: r.one.books({ + from: r.favoriteBooks.bookId, + to: r.books.id, + }), + }, + }), +); + +const db = drizzle(process.env.DATABASE_URL!, { relations }); export { db }; diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts index 38acef1..26577af 100644 --- a/backend/src/db/schema/auth.ts +++ b/backend/src/db/schema/auth.ts @@ -1,4 +1,3 @@ -import { defineRelationsPart } from "drizzle-orm"; import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; const user = pgTable("user", { @@ -74,34 +73,6 @@ const verification = pgTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); -const authRelations = defineRelationsPart( - { user, session, account, verification }, - (r) => ({ - user: { - sessions: r.many.session({ - from: r.user.id, - to: r.session.userId, - }), - accounts: r.many.account({ - from: r.user.id, - to: r.account.userId, - }), - }, - session: { - user: r.one.user({ - from: r.session.userId, - to: r.user.id, - }), - }, - account: { - user: r.one.user({ - from: r.account.userId, - to: r.user.id, - }), - }, - }), -); - type SelectUser = typeof user.$inferSelect; type InsertUser = typeof user.$inferInsert; @@ -119,7 +90,6 @@ export { session, account, verification, - authRelations, type SelectUser, type InsertUser, type SelectSession, diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 614aa82..99b66d7 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -1,4 +1,3 @@ -import { defineRelations } from "drizzle-orm"; import { integer, pgTable, @@ -41,18 +40,6 @@ const bookToGenres = pgTable( (t) => [primaryKey({ columns: [t.bookId, t.genreId] })], ); -const relations = defineRelations({ books, bookGenres, bookToGenres }, (r) => ({ - books: { - genres: r.many.bookGenres({ - from: r.books.id.through(r.bookToGenres.bookId), - to: r.bookGenres.id.through(r.bookToGenres.genreId), - }), - }, - bookGenres: { - books: r.many.books(), - }, -})); - type InsertBook = typeof books.$inferInsert; type SelectBook = typeof books.$inferSelect; @@ -66,7 +53,6 @@ export { books, bookGenres, bookToGenres, - relations, type InsertBook, type SelectBook, type InsertBookGenre, diff --git a/backend/src/db/schema/favorites.ts b/backend/src/db/schema/favorites.ts new file mode 100644 index 0000000..4e5d87b --- /dev/null +++ b/backend/src/db/schema/favorites.ts @@ -0,0 +1,28 @@ +import { + integer, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { user } from "./auth"; +import { books } from "./books"; + +const favoriteBooks = pgTable( + "favorite_books", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.bookId] })], +); + +type InsertFavoriteBook = typeof favoriteBooks.$inferInsert; +type SelectFavoriteBook = typeof favoriteBooks.$inferSelect; + +export { favoriteBooks, type InsertFavoriteBook, type SelectFavoriteBook }; diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index f48a322..341a815 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; export * from "./books"; +export * from "./favorites"; From 25b54d817de68e6a49433e91abe8a3420cf13169 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 23:27:25 +0200 Subject: [PATCH 10/21] chore: add Mutation type to base GraphQL schema --- backend/src/schema/base/schema.graphql | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/schema/base/schema.graphql b/backend/src/schema/base/schema.graphql index a4dda08..515ca91 100644 --- a/backend/src/schema/base/schema.graphql +++ b/backend/src/schema/base/schema.graphql @@ -1 +1,2 @@ type Query +type Mutation From 47bacbe9355630644d7bb418ece99b837d0fbe60 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 23:29:22 +0200 Subject: [PATCH 11/21] feat: add GraphQL schema and resolvers for favorite books --- backend/src/schema/books/resolvers/Book.ts | 18 +++++++++- .../src/schema/books/resolvers/Query/book.ts | 1 + backend/src/schema/books/schema.graphql | 1 + .../resolvers/Mutation/addFavoriteBook.ts | 36 +++++++++++++++++++ .../resolvers/Mutation/removeFavoriteBook.ts | 29 +++++++++++++++ .../resolvers/Query/favoriteBooks.ts | 34 ++++++++++++++++++ backend/src/schema/favorites/schema.graphql | 9 +++++ 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts create mode 100644 backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts create mode 100644 backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts create mode 100644 backend/src/schema/favorites/schema.graphql diff --git a/backend/src/schema/books/resolvers/Book.ts b/backend/src/schema/books/resolvers/Book.ts index 93bd77e..8501feb 100644 --- a/backend/src/schema/books/resolvers/Book.ts +++ b/backend/src/schema/books/resolvers/Book.ts @@ -1,3 +1,19 @@ import type { BookResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; -export const Book: BookResolvers = {}; +export const Book: BookResolvers = { + // Fetch the books genres for a given book if requested + async genres(parent) { + const book = await db.query.books.findFirst({ + // Do not fetch any columns from the books table, we don't need them + columns: {}, + where: { + id: parent.id, + }, + with: { + genres: true, + }, + }); + return book?.genres.map((g) => g.genre) ?? []; + }, +}; diff --git a/backend/src/schema/books/resolvers/Query/book.ts b/backend/src/schema/books/resolvers/Query/book.ts index efd2be1..e2f3fa2 100644 --- a/backend/src/schema/books/resolvers/Query/book.ts +++ b/backend/src/schema/books/resolvers/Query/book.ts @@ -1,5 +1,6 @@ import type { QueryResolvers } from "./../../../types.generated"; import { db } from "~/db"; + export const book: NonNullable = async ( _parent, args, diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index cd16580..af3f5df 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -12,6 +12,7 @@ type Book { publisher: String publishDate: String coverImgUrl: String + genres: [String!]! } extend type Query { diff --git a/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts b/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts new file mode 100644 index 0000000..08a058d --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts @@ -0,0 +1,36 @@ +import type { MutationResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { GraphQLError } from "graphql/error"; +import { favoriteBooks } from "~/db/schema"; + +export const addFavoriteBook: NonNullable< + MutationResolvers["addFavoriteBook"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to add a book to your favorites.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const book = await db.query.books.findFirst({ + where: { + id: arg.bookId, + }, + }); + + if (!book) { + throw new GraphQLError("Book not found.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + await db + .insert(favoriteBooks) + .values({ userId: ctx.session.user.id, bookId: arg.bookId }) + .onConflictDoNothing(); + + return book; +}; diff --git a/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts b/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts new file mode 100644 index 0000000..3a2d8a1 --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts @@ -0,0 +1,29 @@ +import { GraphQLError } from "graphql/error"; +import type { MutationResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { favoriteBooks } from "~/db/schema"; +import { and, eq } from "drizzle-orm"; + +export const removeFavoriteBook: NonNullable< + MutationResolvers["removeFavoriteBook"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to remove a book from your favorites.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + await db + .delete(favoriteBooks) + .where( + and( + eq(favoriteBooks.userId, ctx.session.user.id), + eq(favoriteBooks.bookId, arg.bookId), + ), + ); + + return true; +}; diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts new file mode 100644 index 0000000..08e7855 --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts @@ -0,0 +1,34 @@ +import { GraphQLError } from "graphql/error"; +import type { QueryResolvers } from "./../../../types.generated"; +import { db } from "~/db"; + +export const favoriteBooks: NonNullable< + QueryResolvers["favoriteBooks"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to view your favorite books.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const favoriteBooks = await db.query.favoriteBooks.findMany({ + where: { + userId: ctx.session.user.id, + }, + with: { + book: true, + }, + orderBy: { + createdAt: "desc", + }, + limit: 12, + offset: arg.offset, + }); + + return favoriteBooks + .filter((favoriteBook) => favoriteBook.book) + .map((favoriteBook) => favoriteBook.book!); +}; diff --git a/backend/src/schema/favorites/schema.graphql b/backend/src/schema/favorites/schema.graphql new file mode 100644 index 0000000..ad28300 --- /dev/null +++ b/backend/src/schema/favorites/schema.graphql @@ -0,0 +1,9 @@ +extend type Query { + "Favorite books of the logged-in user, newest first." + favoriteBooks(offset: Int = 0): [Book!]! +} + +extend type Mutation { + addFavoriteBook(bookId: Int!): Book! + removeFavoriteBook(bookId: Int!): Boolean! +} From 44d6f0961704d7dabee3906542ed50d1b16163d1 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 23:30:59 +0200 Subject: [PATCH 12/21] chore: generate new GraphQL types --- backend/src/schema/resolvers.generated.ts | 7 ++- backend/src/schema/schema.generated.graphqls | 9 ++++ backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 47 +++++++++++++++++--- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 022f932..e8afab5 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -1,12 +1,15 @@ /* This file was automatically generated. DO NOT UPDATE MANUALLY. */ import type { Resolvers } from './types.generated'; import { book as Query_book } from './books/resolvers/Query/book'; +import { favoriteBooks as Query_favoriteBooks } from './favorites/resolvers/Query/favoriteBooks'; import { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks'; +import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook'; +import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook'; import { Book } from './books/resolvers/Book'; import { BookSearchResult } from './books/resolvers/BookSearchResult'; export const resolvers: Resolvers = { - Query: { book: Query_book,searchBooks: Query_searchBooks }, - + Query: { book: Query_book,favoriteBooks: Query_favoriteBooks,searchBooks: Query_searchBooks }, + Mutation: { addFavoriteBook: Mutation_addFavoriteBook,removeFavoriteBook: Mutation_removeFavoriteBook }, Book: Book, BookSearchResult: BookSearchResult diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 5ac6a75..b288189 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -4,6 +4,7 @@ type Book { coverImgUrl: String description: String edition: String + genres: [String!]! id: Int! isbn: String! language: String @@ -19,7 +20,15 @@ type BookSearchResult { totalCount: Int! } +type Mutation { + addFavoriteBook(bookId: Int!): Book! + removeFavoriteBook(bookId: Int!): Boolean! +} + type Query { book(id: Int!): Book + + """Favorite books of the logged-in user, newest first.""" + favoriteBooks(offset: Int = 0): [Book!]! searchBooks(author: String, bookFormat: String, language: String, limit: Int = 12, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! } \ No newline at end of file diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 7c33094..16b40ff 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index a4712cf..3a7cf8c 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -1,4 +1,5 @@ import type { GraphQLResolveInfo } from 'graphql'; +import type { ApolloContext } from '~/index'; export type Maybe = T | null | undefined; export type InputMaybe = T | null | undefined; export type RequireFields = Omit & { [P in K]-?: NonNullable }; @@ -18,6 +19,7 @@ export type Book = { coverImgUrl?: Maybe; description?: Maybe; edition?: Maybe; + genres: Array; id: Scalars['Int']['output']; isbn: Scalars['String']['output']; language?: Maybe; @@ -34,9 +36,27 @@ export type BookSearchResult = { totalCount: Scalars['Int']['output']; }; +export type Mutation = { + __typename?: 'Mutation'; + addFavoriteBook: Book; + removeFavoriteBook: Scalars['Boolean']['output']; +}; + + +export type MutationaddFavoriteBookArgs = { + bookId: Scalars['Int']['input']; +}; + + +export type MutationremoveFavoriteBookArgs = { + bookId: Scalars['Int']['input']; +}; + export type Query = { __typename?: 'Query'; book?: Maybe; + /** Favorite books of the logged-in user, newest first. */ + favoriteBooks: Array; searchBooks: BookSearchResult; }; @@ -46,6 +66,11 @@ export type QuerybookArgs = { }; +export type QueryfavoriteBooksArgs = { + offset?: InputMaybe; +}; + + export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; @@ -134,8 +159,9 @@ export type ResolversTypes = { String: ResolverTypeWrapper; Int: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper; - Query: ResolverTypeWrapper>; + Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; + Query: ResolverTypeWrapper>; }; /** Mapping between all available schema types and the resolvers parents */ @@ -144,16 +170,18 @@ export type ResolversParentTypes = { String: Scalars['String']['output']; Int: Scalars['Int']['output']; BookSearchResult: BookSearchResult; - Query: Record; + Mutation: Record; Boolean: Scalars['Boolean']['output']; + Query: Record; }; -export type BookResolvers = { +export type BookResolvers = { author?: Resolver; bookFormat?: Resolver; coverImgUrl?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; edition?: Resolver, ParentType, ContextType>; + genres?: Resolver, ParentType, ContextType>; id?: Resolver; isbn?: Resolver; language?: Resolver, ParentType, ContextType>; @@ -164,19 +192,26 @@ export type BookResolvers; }; -export type BookSearchResultResolvers = { +export type BookSearchResultResolvers = { books?: Resolver, ParentType, ContextType>; totalCount?: Resolver; }; -export type QueryResolvers = { +export type MutationResolvers = { + addFavoriteBook?: Resolver>; + removeFavoriteBook?: Resolver>; +}; + +export type QueryResolvers = { book?: Resolver, ParentType, ContextType, RequireFields>; + favoriteBooks?: Resolver, ParentType, ContextType, RequireFields>; searchBooks?: Resolver>; }; -export type Resolvers = { +export type Resolvers = { Book?: BookResolvers; BookSearchResult?: BookSearchResultResolvers; + Mutation?: MutationResolvers; Query?: QueryResolvers; }; From d460367f13d252e1d18b479ed892c75b3d940796 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 13 Oct 2026 23:34:12 +0200 Subject: [PATCH 13/21] feat: include credentials when using ApolloClient This is because the backend is hosted on a different port, so it needs the proper headers --- frontend/app/root.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index 4a24668..b7476f3 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -18,7 +18,10 @@ import { TooltipProvider } from "~/components/ui/Tooltip"; // Created outside the component tree so the cache survives re-renders. const apolloClient = new ApolloClient({ - link: new HttpLink({ uri: "http://localhost:3001/" }), + link: new HttpLink({ + uri: "http://localhost:3001/", + credentials: "include", + }), cache: new InMemoryCache(), }); From 4735e6e7be383d45b9b8963a3a403fda69ef8696 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 14 Oct 2026 00:20:42 +0200 Subject: [PATCH 14/21] feat: add Profile page to share your favorite books --- frontend/app/api/auth.ts | 11 ++ frontend/app/api/toBook.ts | 4 +- frontend/app/api/useFavoriteBooks.ts | 92 +++++++++++ .../FavoriteButton/FavoriteButton.tsx | 101 ++++++++---- frontend/app/routes.ts | 1 + frontend/app/routes/profile.tsx | 146 ++++++++++++++++++ frontend/app/test/setup.ts | 4 +- 7 files changed, 325 insertions(+), 34 deletions(-) create mode 100644 frontend/app/api/useFavoriteBooks.ts create mode 100644 frontend/app/routes/profile.tsx diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts index 6df54a9..e535eb9 100644 --- a/frontend/app/api/auth.ts +++ b/frontend/app/api/auth.ts @@ -1,7 +1,18 @@ import { createAuthClient } from "better-auth/react"; +import { inferAdditionalFields } from "better-auth/client/plugins"; const authClient = createAuthClient({ baseURL: "http://localhost:3001", + plugins: [ + inferAdditionalFields({ + user: { + bio: { + type: "string", + required: false, + }, + }, + }), + ], }); export { authClient }; diff --git a/frontend/app/api/toBook.ts b/frontend/app/api/toBook.ts index 2e82e19..a0f70a4 100644 --- a/frontend/app/api/toBook.ts +++ b/frontend/app/api/toBook.ts @@ -1,6 +1,7 @@ import type { Book } from "~/api/types"; import type { BookQuery, + FavoriteBooksQuery, SearchBooksQuery, } from "~/api/types/graphql.generated"; @@ -19,7 +20,8 @@ function yearFromPublishDate(publishDate: string | null): number | undefined { export function toBook( book: | NonNullable - | NonNullable["books"][number], + | NonNullable["books"][number] + | FavoriteBooksQuery["favoriteBooks"][number], ): Book { return { id: String(book.id), diff --git a/frontend/app/api/useFavoriteBooks.ts b/frontend/app/api/useFavoriteBooks.ts new file mode 100644 index 0000000..baafd74 --- /dev/null +++ b/frontend/app/api/useFavoriteBooks.ts @@ -0,0 +1,92 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { toBook } from "~/api/toBook"; +import type { + AddFavoriteBookMutation, + AddFavoriteBookMutationVariables, + FavoriteBooksQuery, + FavoriteBooksQueryVariables, + RemoveFavoriteBookMutation, + RemoveFavoriteBookMutationVariables, +} from "~/api/types/graphql.generated"; + +export const FAVORITE_BOOKS: TypedDocumentNode< + FavoriteBooksQuery, + FavoriteBooksQueryVariables +> = gql` + query FavoriteBooks { + favoriteBooks { + id + title + author + publishDate + coverImgUrl + genres + } + } +`; + +export const ADD_FAVORITE_BOOK: TypedDocumentNode< + AddFavoriteBookMutation, + AddFavoriteBookMutationVariables +> = gql` + mutation AddFavoriteBook($bookId: Int!) { + addFavoriteBook(bookId: $bookId) { + id + } + } +`; + +export const REMOVE_FAVORITE_BOOK: TypedDocumentNode< + RemoveFavoriteBookMutation, + RemoveFavoriteBookMutationVariables +> = gql` + mutation RemoveFavoriteBook($bookId: Int!) { + removeFavoriteBook(bookId: $bookId) + } +`; + +export const useFavoriteBooks = ({ enabled }: { enabled: boolean }) => { + const { data, loading, error } = useQuery(FAVORITE_BOOKS, { skip: !enabled }); + const favoriteBooks = data?.favoriteBooks ?? []; + + // Refetching keeps every FavoriteButton and the profile page in sync. + const options = { + refetchQueries: [FAVORITE_BOOKS], + awaitRefetchQueries: true, + }; + const [addFavoriteBook, { loading: adding }] = useMutation( + ADD_FAVORITE_BOOK, + options, + ); + const [removeFavoriteBook, { loading: removing }] = useMutation( + REMOVE_FAVORITE_BOOK, + options, + ); + + const genreCounts = new Map(); + for (const book of favoriteBooks) { + for (const genre of book.genres) { + genreCounts.set(genre, (genreCounts.get(genre) ?? 0) + 1); + } + } + + const topGenres = [...genreCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([genre]) => genre); + + return { + books: favoriteBooks.map(toBook), + topGenres, + loading, + error, + isFavorite: (bookId: string) => + favoriteBooks.some((book) => String(book.id) === bookId), + addFavoriteBook: (bookId: string) => + addFavoriteBook({ variables: { bookId: Number(bookId) } }), + removeFavoriteBook: (bookId: string) => + removeFavoriteBook({ variables: { bookId: Number(bookId) } }), + saving: adding || removing, + }; +}; diff --git a/frontend/app/components/FavoriteButton/FavoriteButton.tsx b/frontend/app/components/FavoriteButton/FavoriteButton.tsx index 26c74e3..33e92bf 100644 --- a/frontend/app/components/FavoriteButton/FavoriteButton.tsx +++ b/frontend/app/components/FavoriteButton/FavoriteButton.tsx @@ -1,5 +1,19 @@ import { useState } from "react"; +import { Link } from "react-router"; +import { authClient } from "~/api/auth"; +import { useFavoriteBooks } from "~/api/useFavoriteBooks"; import { CheckIcon } from "~/components/icons/CheckIcon"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/AlertDialog"; +import { buttonVariants } from "~/components/ui/Button"; +import { toast } from "~/components/ui/Toast"; import type { Book } from "~/api/types"; interface FavoriteButtonProps { @@ -7,41 +21,68 @@ interface FavoriteButtonProps { } function FavoriteButton({ book }: FavoriteButtonProps) { - const [favorites, setFavorites] = useState(() => { - const rawFavorites = localStorage.getItem("favorites"); - return JSON.parse(rawFavorites ?? "[]") as string[]; - }); - - const saveFavorite = () => { - const rawFavorites = localStorage.getItem("favorites"); - let savedFavorites = JSON.parse(rawFavorites ?? "[]") as string[]; - - if (savedFavorites.includes(book.id)) { - // Remove from favorites if already present - savedFavorites = savedFavorites.filter((id) => id !== book.id); - } else { - savedFavorites.push(book.id); + const { data: session } = authClient.useSession(); + const { isFavorite, addFavoriteBook, removeFavoriteBook, saving } = + useFavoriteBooks({ enabled: !!session }); + + const [loginDialogOpen, setLoginDialogOpen] = useState(false); + + const favorite = isFavorite(book.id); + + const toggleFavorite = async () => { + if (!session) { + setLoginDialogOpen(true); + return; } - localStorage.setItem("favorites", JSON.stringify(savedFavorites)); - setFavorites(savedFavorites); + try { + if (favorite) { + await removeFavoriteBook(book.id); + } else { + await addFavoriteBook(book.id); + } + } catch { + toast.add({ + description: "Could not update your favorites. Please try again.", + type: "error", + }); + } }; return ( - + <> + + + + + Log in to save favorites + + You need an account to save {book.title} to your favorites. + + + + Cancel + + Log in + + + + + ); } diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index e996351..81018db 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -5,4 +5,5 @@ export default [ route("book/:bookId", "routes/book/selectedBook.tsx"), route("signup", "routes/signup.tsx"), route("login", "routes/login.tsx"), + route("profile", "routes/profile.tsx"), ] satisfies RouteConfig; diff --git a/frontend/app/routes/profile.tsx b/frontend/app/routes/profile.tsx new file mode 100644 index 0000000..e159071 --- /dev/null +++ b/frontend/app/routes/profile.tsx @@ -0,0 +1,146 @@ +import { Link, Navigate } from "react-router"; +import { authClient } from "~/api/auth"; +import { useFavoriteBooks } from "~/api/useFavoriteBooks"; +import { BookCard } from "~/components/BookCard/BookCard"; +import { Avatar, AvatarFallback } from "~/components/ui/Avatar"; +import { Badge } from "~/components/ui/Badge"; +import { buttonVariants } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { Skeleton } from "~/components/ui/Skeleton"; + +export function meta() { + return [{ title: "Profile | Readr" }]; +} + +export default function ProfilePage() { + const { data: session, isPending } = authClient.useSession(); + const { books, topGenres, loading, error } = useFavoriteBooks({ + enabled: !!session, + }); + + if (isPending) { + return ( +
+ + +
+ ); + } + + if (!session) { + return ; + } + + const { user } = session; + + const nameParts = user.name.split(" "); + const initialsText = + nameParts.length > 1 ? nameParts[0][0] + nameParts[1][0] : nameParts[0][0]; + + return ( +
+ + + + {initialsText} + + +

{user.name}

+
+ {user.email} +
+ + {user.bio ? ( +

{user.bio}

+ ) : ( +

No bio yet.

+ )} +

+ Joined{" "} + +

+ {topGenres.length > 0 && ( +
+

Favorite genres

+
    + {topGenres.map((genre) => ( +
  • + {genre} +
  • + ))} +
+
+ )} +
+ + + Edit profile + + +
+ +
+

+ Favorite books +

+ {loading ? ( + + ) : error ? ( +

Could not load your favorite books. Please try again later.

+ ) : books.length === 0 ? ( +

+ You have no favorite books yet.{" "} + + Browse books + +

+ ) : ( +
    + {books.map((book) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/test/setup.ts b/frontend/app/test/setup.ts index 69ba838..66f2d6f 100644 --- a/frontend/app/test/setup.ts +++ b/frontend/app/test/setup.ts @@ -8,8 +8,6 @@ import { cleanup } from "@testing-library/react"; afterEach(() => { // Unmount everything rendered by the previous test. cleanup(); - // FavoriteButton stores favorites in localStorage and App reads filters - // from sessionStorage; every test starts clean. - localStorage.clear(); + // App reads filters from sessionStorage; every test starts clean. sessionStorage.clear(); }); From e82bbceffeeb300d95897e5b5497fe5ed57c7719 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 14 Oct 2026 00:21:05 +0200 Subject: [PATCH 15/21] feat: make project name and user name in header clickable --- frontend/app/components/Header/Header.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/app/components/Header/Header.tsx b/frontend/app/components/Header/Header.tsx index 4f15604..52622f1 100644 --- a/frontend/app/components/Header/Header.tsx +++ b/frontend/app/components/Header/Header.tsx @@ -7,10 +7,14 @@ function Header() { return (
-

Readr

+ +

Readr

+ {!isPending && session ? (
-

{session.user.name}

+ + {session.user.name} + From e7ffb09fa614fed55b9feec02c9fb21a11141fe2 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 14 Oct 2026 00:22:26 +0200 Subject: [PATCH 16/21] chore: add generated GraphQL types to frontend --- frontend/app/api/types/graphql.generated.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index ae4a7af..5c5a99c 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -9,6 +9,25 @@ export type BookQueryVariables = Exact<{ export type BookQuery = { book: { __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, description: string | null } | null }; +export type FavoriteBooksQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FavoriteBooksQuery = { favoriteBooks: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, genres: Array }> }; + +export type AddFavoriteBookMutationVariables = Exact<{ + bookId: number; +}>; + + +export type AddFavoriteBookMutation = { addFavoriteBook: { __typename: 'Book', id: number } }; + +export type RemoveFavoriteBookMutationVariables = Exact<{ + bookId: number; +}>; + + +export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; + export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; limit?: number | null | undefined; From df6ce044b292d8253e8b341196c45e6083611fff Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 14 Oct 2026 00:34:17 +0200 Subject: [PATCH 17/21] feat: add settings page to allow changing profile and password --- .../app/components/settings/PasswordForm.tsx | 209 ++++++++++++++++++ .../app/components/settings/ProfileForm.tsx | 160 ++++++++++++++ frontend/app/routes.ts | 1 + frontend/app/routes/settings.tsx | 44 ++++ 4 files changed, 414 insertions(+) create mode 100644 frontend/app/components/settings/PasswordForm.tsx create mode 100644 frontend/app/components/settings/ProfileForm.tsx create mode 100644 frontend/app/routes/settings.tsx diff --git a/frontend/app/components/settings/PasswordForm.tsx b/frontend/app/components/settings/PasswordForm.tsx new file mode 100644 index 0000000..636985a --- /dev/null +++ b/frontend/app/components/settings/PasswordForm.tsx @@ -0,0 +1,209 @@ +import { useForm } from "@tanstack/react-form"; +import { useState } from "react"; +import z from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { toast } from "~/components/ui/Toast"; + +// Make sure this matches the minimum password length set in backend/src/auth/index.ts +const MIN_PASSWORD_LENGTH = 8; + +const passwordFormSchema = z.object({ + currentPassword: z.string().min(1, "Enter your current password."), + newPassword: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`, + ), + confirmPassword: z.string().min(1, "Confirm your new password."), +}); + +function PasswordForm() { + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + validators: { + onSubmit: passwordFormSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error } = await authClient.changePassword({ + currentPassword: value.currentPassword, + newPassword: value.newPassword, + revokeOtherSessions: true, + }); + + if (error) { + setServerError( + error.message ?? "Could not change your password. Please try again.", + ); + return; + } + + toast.add({ + description: "Your password has been changed.", + type: "success", + }); + form.reset(); + }, + }); + + return ( + + + +

Password

+
+ + Changing your password logs you out on your other devices. + +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Current password + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="current-password" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + New password + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + aria-describedby="new-password-description" + autoComplete="new-password" + /> + + At least {MIN_PASSWORD_LENGTH} characters. + + {isInvalid && ( + + )} + + ); + }} + + { + if (value !== fieldApi.form.getFieldValue("newPassword")) { + return { message: "Passwords do not match." }; + } + return undefined; + }, + }} + > + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Confirm new password + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="new-password" + /> + {isInvalid && ( + + )} + + ); + }} + + {serverError} + +
+
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + +
+ ); +} + +export { PasswordForm }; diff --git a/frontend/app/components/settings/ProfileForm.tsx b/frontend/app/components/settings/ProfileForm.tsx new file mode 100644 index 0000000..1ca673b --- /dev/null +++ b/frontend/app/components/settings/ProfileForm.tsx @@ -0,0 +1,160 @@ +import { useForm } from "@tanstack/react-form"; +import { useState } from "react"; +import z from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { Textarea } from "~/components/ui/Textarea"; +import { toast } from "~/components/ui/Toast"; + +const MAX_BIO_LENGTH = 300; + +const profileFormSchema = z.object({ + name: z.string().trim().min(1, "Enter your name."), + bio: z + .string() + .max(MAX_BIO_LENGTH, `Bio must be at most ${MAX_BIO_LENGTH} characters.`), +}); + +function ProfileForm({ name, bio }: { name: string; bio: string }) { + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + name, + bio, + }, + validators: { + onSubmit: profileFormSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error } = await authClient.updateUser({ + name: value.name.trim(), + bio: value.bio, + }); + + if (error) { + setServerError( + error.message ?? "Could not update your profile. Please try again.", + ); + return; + } + + toast.add({ + description: "Your profile has been updated.", + type: "success", + }); + }, + }); + + return ( + + + +

Profile

+
+ + Change how your name and bio appear on your profile. + +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Name + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="name" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Bio +