From b8383108720526ab883a6276fdf42cc63a7ffe98 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Thu, 10 Jul 2025 23:15:37 +0530 Subject: [PATCH] Added Better Auth --- .env.example | 2 + bun.lock | 47 + docs/BETTER_AUTH_MIGRATION.md | 175 +++ drizzle/0001_vengeful_whirlwind.sql | 45 + drizzle/meta/0001_snapshot.json | 1261 +++++++++++++++++ drizzle/meta/_journal.json | 7 + env.d.ts | 9 + package.json | 2 + scripts/generate-better-auth-schema.ts | 109 ++ scripts/migrate-to-better-auth.ts | 85 ++ src/components/auth/LoginForm.tsx | 47 +- src/components/auth/SignupForm.tsx | 29 +- src/hooks/useAuth-legacy.ts | 147 ++ src/hooks/useAuth.ts | 128 +- src/lib/auth-client.ts | 22 + src/lib/auth-config.ts | 76 + src/lib/auth-oidc-config.example.ts | 179 +++ src/lib/auth.ts | 64 + src/lib/db/index.ts | 22 +- src/lib/db/schema.ts | 66 + src/lib/utils/auth-helpers.ts | 58 + src/middleware.ts | 20 + src/pages/api/auth/[...all].ts | 10 + src/pages/api/auth/check-users.ts | 30 + src/pages/api/auth/legacy-backup/README.md | 13 + .../api/auth/{ => legacy-backup}/index.ts | 0 .../api/auth/{ => legacy-backup}/login.ts | 0 .../api/auth/{ => legacy-backup}/logout.ts | 0 .../api/auth/{ => legacy-backup}/register.ts | 0 src/pages/api/organizations/[id].ts | 35 +- src/pages/api/repositories/[id].ts | 35 +- src/pages/index.astro | 7 +- src/pages/login.astro | 9 +- src/pages/signup.astro | 9 +- 34 files changed, 2573 insertions(+), 175 deletions(-) create mode 100644 docs/BETTER_AUTH_MIGRATION.md create mode 100644 drizzle/0001_vengeful_whirlwind.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 env.d.ts create mode 100644 scripts/generate-better-auth-schema.ts create mode 100644 scripts/migrate-to-better-auth.ts create mode 100644 src/hooks/useAuth-legacy.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/auth-config.ts create mode 100644 src/lib/auth-oidc-config.example.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/utils/auth-helpers.ts create mode 100644 src/pages/api/auth/[...all].ts create mode 100644 src/pages/api/auth/check-users.ts create mode 100644 src/pages/api/auth/legacy-backup/README.md rename src/pages/api/auth/{ => legacy-backup}/index.ts (100%) rename src/pages/api/auth/{ => legacy-backup}/login.ts (100%) rename src/pages/api/auth/{ => legacy-backup}/logout.ts (100%) rename src/pages/api/auth/{ => legacy-backup}/register.ts (100%) diff --git a/.env.example b/.env.example index 8805438..36cae22 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ DATABASE_URL=sqlite://data/gitea-mirror.db # Security JWT_SECRET=change-this-to-a-secure-random-string-in-production +BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production +BETTER_AUTH_URL=http://localhost:3000 # Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI) # Uncomment and set as needed. These are passed as environment variables to the container. diff --git a/bun.lock b/bun.lock index db69a4a..ab550f6 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@types/react-dom": "^19.1.6", "astro": "5.11.0", "bcryptjs": "^3.0.2", + "better-auth": "^1.2.12", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -135,6 +136,10 @@ "@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="], + "@better-auth/utils": ["@better-auth/utils@0.2.5", "", { "dependencies": { "typescript": "^5.8.2", "uncrypto": "^0.1.3" } }, "sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="], + "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="], @@ -227,6 +232,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -277,8 +284,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], + "@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -311,6 +324,16 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.3.16", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.3.15", "", { "dependencies": { "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -459,6 +482,10 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.1.2", "", {}, "sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.1.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8" } }, "sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -607,6 +634,8 @@ "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -625,6 +654,10 @@ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "better-auth": ["better-auth@1.2.12", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.8", "defu": "^6.1.4", "jose": "^6.0.11", "kysely": "^0.28.2", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-YicCyjQ+lxb7YnnaCewrVOjj3nPVa0xcfrOJK7k5MLMX9Mt9UnJ8GYaVQNHOHLyVxl92qc3C758X1ihqAUzm4w=="], + + "better-call": ["better-call@1.0.12", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg=="], + "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], @@ -939,6 +972,8 @@ "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -961,6 +996,8 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "kysely": ["kysely@0.28.2", "", {}, "sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A=="], + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], @@ -1147,6 +1184,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -1215,6 +1254,10 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], @@ -1299,6 +1342,8 @@ "rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="], + "rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1317,6 +1362,8 @@ "server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], diff --git a/docs/BETTER_AUTH_MIGRATION.md b/docs/BETTER_AUTH_MIGRATION.md new file mode 100644 index 0000000..019904f --- /dev/null +++ b/docs/BETTER_AUTH_MIGRATION.md @@ -0,0 +1,175 @@ +# Better Auth Migration Guide + +This document describes the migration from the legacy authentication system to Better Auth. + +## Overview + +Gitea Mirror has been migrated to use Better Auth, a modern authentication library that provides: +- Built-in support for email/password authentication +- Session management with secure cookies +- Database adapter with Drizzle ORM +- Ready for OAuth2, OIDC, and SSO integrations +- Type-safe authentication throughout the application + +## Key Changes + +### 1. Database Schema + +New tables added: +- `sessions` - User session management +- `accounts` - Authentication providers (credentials, OAuth, etc.) +- `verification_tokens` - Email verification and password reset tokens + +Modified tables: +- `users` - Added `emailVerified` field + +### 2. Authentication Flow + +**Login:** +- Users now log in with email instead of username +- Endpoint: `/api/auth/sign-in/email` +- Session cookies are automatically managed + +**Registration:** +- Users register with username, email, and password +- Username is stored as an additional field +- Endpoint: `/api/auth/sign-up/email` + +### 3. API Routes + +All auth routes are now handled by Better Auth's catch-all handler: +- `/api/auth/[...all].ts` handles all authentication endpoints + +Legacy routes have been backed up to `/src/pages/api/auth/legacy-backup/` + +### 4. Session Management + +Sessions are now managed by Better Auth: +- Middleware automatically populates `context.locals.user` and `context.locals.session` +- Use `useAuth()` hook in React components for client-side auth +- Sessions expire after 30 days by default + +## Future OIDC/SSO Configuration + +The project is now ready for OIDC and SSO integrations. To enable: + +### 1. Enable SSO Plugin + +```typescript +// src/lib/auth.ts +import { sso } from "better-auth/plugins/sso"; + +export const auth = betterAuth({ + // ... existing config + plugins: [ + sso({ + provisionUser: async (data) => { + // Custom user provisioning logic + return data; + }, + }), + ], +}); +``` + +### 2. Register OIDC Providers + +```typescript +// Example: Register an OIDC provider +await authClient.sso.register({ + issuer: "https://idp.example.com", + domain: "example.com", + clientId: "your-client-id", + clientSecret: "your-client-secret", + providerId: "example-provider", +}); +``` + +### 3. Enable OIDC Provider Mode + +To make Gitea Mirror act as an OIDC provider: + +```typescript +// src/lib/auth.ts +import { oidcProvider } from "better-auth/plugins/oidc"; + +export const auth = betterAuth({ + // ... existing config + plugins: [ + oidcProvider({ + loginPage: "/signin", + consentPage: "/oauth/consent", + metadata: { + issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000", + }, + }), + ], +}); +``` + +### 4. Database Migration for SSO + +When enabling SSO/OIDC, run migrations to add required tables: + +```bash +# Generate the schema +bun drizzle-kit generate + +# Apply the migration +bun drizzle-kit migrate +``` + +New tables that will be added: +- `sso_providers` - SSO provider configurations +- `oauth_applications` - OAuth2 client applications +- `oauth_access_tokens` - OAuth2 access tokens +- `oauth_consents` - User consent records + +## Environment Variables + +Required environment variables: + +```env +# Better Auth configuration +BETTER_AUTH_SECRET=your-secret-key +BETTER_AUTH_URL=http://localhost:3000 + +# Legacy (kept for compatibility) +JWT_SECRET=your-secret-key +``` + +## Migration Script + +To migrate existing users to Better Auth: + +```bash +bun run migrate:better-auth +``` + +This script: +1. Creates credential accounts for existing users +2. Moves password hashes to the accounts table +3. Preserves user creation dates + +## Troubleshooting + +### Login Issues +- Ensure users log in with email, not username +- Check that BETTER_AUTH_SECRET is set +- Verify database migrations have been applied + +### Session Issues +- Clear browser cookies if experiencing session problems +- Check middleware is properly configured +- Ensure auth routes are accessible at `/api/auth/*` + +### Development Tips +- Use `bun db:studio` to inspect database tables +- Check `/api/auth/session` to verify current session +- Enable debug logging in Better Auth for troubleshooting + +## Resources + +- [Better Auth Documentation](https://better-auth.com) +- [Better Auth Astro Integration](https://better-auth.com/docs/integrations/astro) +- [Better Auth Plugins](https://better-auth.com/docs/plugins) \ No newline at end of file diff --git a/drizzle/0001_vengeful_whirlwind.sql b/drizzle/0001_vengeful_whirlwind.sql new file mode 100644 index 0000000..be593b1 --- /dev/null +++ b/drizzle/0001_vengeful_whirlwind.sql @@ -0,0 +1,45 @@ +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `provider_id` text NOT NULL, + `provider_user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `expires_at` integer, + `password` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_accounts_user_id` ON `accounts` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_accounts_provider` ON `accounts` (`provider_id`,`provider_user_id`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `idx_sessions_user_id` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_sessions_token` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `idx_sessions_expires_at` ON `sessions` (`expires_at`);--> statement-breakpoint +CREATE TABLE `verification_tokens` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `identifier` text NOT NULL, + `type` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `verification_tokens_token_unique` ON `verification_tokens` (`token`);--> statement-breakpoint +CREATE INDEX `idx_verification_tokens_token` ON `verification_tokens` (`token`);--> statement-breakpoint +CREATE INDEX `idx_verification_tokens_identifier` ON `verification_tokens` (`identifier`);--> statement-breakpoint +ALTER TABLE `users` ADD `email_verified` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c636152 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1261 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "590d3057-e9b0-4113-af8e-8dc2c3d8d737", + "prevId": "b963d828-412d-4192-b0aa-3b13b83cfba8", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_accounts_provider": { + "name": "idx_accounts_provider", + "columns": [ + "provider_id", + "provider_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "github_config": { + "name": "github_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gitea_config": { + "name": "gitea_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include": { + "name": "include", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"*\"]'" + }, + "exclude": { + "name": "exclude", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_config": { + "name": "cleanup_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "configs_user_id_users_id_fk": { + "name": "configs_user_id_users_id_fk", + "tableFrom": "configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_events_user_channel": { + "name": "idx_events_user_channel", + "columns": [ + "user_id", + "channel" + ], + "isUnique": false + }, + "idx_events_created_at": { + "name": "idx_events_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_events_read": { + "name": "idx_events_read", + "columns": [ + "read" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mirror_jobs": { + "name": "mirror_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mirror'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_items": { + "name": "completed_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_item_ids": { + "name": "completed_item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checkpoint": { + "name": "last_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mirror_jobs_user_id": { + "name": "idx_mirror_jobs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_batch_id": { + "name": "idx_mirror_jobs_batch_id", + "columns": [ + "batch_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_in_progress": { + "name": "idx_mirror_jobs_in_progress", + "columns": [ + "in_progress" + ], + "isUnique": false + }, + "idx_mirror_jobs_job_type": { + "name": "idx_mirror_jobs_job_type", + "columns": [ + "job_type" + ], + "isUnique": false + }, + "idx_mirror_jobs_timestamp": { + "name": "idx_mirror_jobs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mirror_jobs_user_id_users_id_fk": { + "name": "mirror_jobs_user_id_users_id_fk", + "tableFrom": "mirror_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "is_included": { + "name": "is_included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_count": { + "name": "repository_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_organizations_user_id": { + "name": "idx_organizations_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_organizations_config_id": { + "name": "idx_organizations_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_organizations_status": { + "name": "idx_organizations_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_organizations_is_included": { + "name": "idx_organizations_is_included", + "columns": [ + "is_included" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organizations_user_id_users_id_fk": { + "name": "organizations_user_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizations_config_id_configs_id_fk": { + "name": "organizations_config_id_configs_id_fk", + "tableFrom": "organizations", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mirrored_location": { + "name": "mirrored_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_fork": { + "name": "is_fork", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_issues": { + "name": "has_issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_starred": { + "name": "is_starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_lfs": { + "name": "has_lfs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_submodules": { + "name": "has_submodules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_repositories_user_id": { + "name": "idx_repositories_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_repositories_config_id": { + "name": "idx_repositories_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_repositories_status": { + "name": "idx_repositories_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_repositories_owner": { + "name": "idx_repositories_owner", + "columns": [ + "owner" + ], + "isUnique": false + }, + "idx_repositories_organization": { + "name": "idx_repositories_organization", + "columns": [ + "organization" + ], + "isUnique": false + }, + "idx_repositories_is_fork": { + "name": "idx_repositories_is_fork", + "columns": [ + "is_fork" + ], + "isUnique": false + }, + "idx_repositories_is_starred": { + "name": "idx_repositories_is_starred", + "columns": [ + "is_starred" + ], + "isUnique": false + } + }, + "foreignKeys": { + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "repositories_config_id_configs_id_fk": { + "name": "repositories_config_id_configs_id_fk", + "tableFrom": "repositories", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_verification_tokens_identifier": { + "name": "idx_verification_tokens_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 423b007..3b8a751 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1752161775910, "tag": "0000_big_xorn", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1752166860985, + "tag": "0001_vengeful_whirlwind", + "breakpoints": true } ] } \ No newline at end of file diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..1369e80 --- /dev/null +++ b/env.d.ts @@ -0,0 +1,9 @@ +/// +/// + +declare namespace App { + interface Locals { + user: import("better-auth").User | null; + session: import("better-auth").Session | null; + } +} \ No newline at end of file diff --git a/package.json b/package.json index d7251c1..3ca4818 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "db:pull": "bun drizzle-kit pull", "db:check": "bun drizzle-kit check", "db:studio": "bun drizzle-kit studio", + "migrate:better-auth": "bun scripts/migrate-to-better-auth.ts", "startup-recovery": "bun scripts/startup-recovery.ts", "startup-recovery-force": "bun scripts/startup-recovery.ts --force", "test-recovery": "bun scripts/test-recovery.ts", @@ -66,6 +67,7 @@ "@types/react-dom": "^19.1.6", "astro": "5.11.0", "bcryptjs": "^3.0.2", + "better-auth": "^1.2.12", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/scripts/generate-better-auth-schema.ts b/scripts/generate-better-auth-schema.ts new file mode 100644 index 0000000..723e55e --- /dev/null +++ b/scripts/generate-better-auth-schema.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env bun + +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import Database from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +// Create a minimal auth instance just for schema generation +const tempDb = new Database(":memory:"); +const db = drizzle({ client: tempDb }); + +// Minimal auth config for schema generation +const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, + }), + emailAndPassword: { + enabled: true, + }, +}); + +// Generate the schema +const schema = auth.$internal.schema; + +console.log("Better Auth Tables Required:"); +console.log("============================"); + +// Convert Better Auth schema to Drizzle schema definitions +const drizzleSchemaCode = `// Better Auth Tables - Generated Schema +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +// Sessions table +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + userId: text("user_id").notNull().references(() => users.id), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql\`(unixepoch())\`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql\`(unixepoch())\`), +}, (table) => { + return { + userIdIdx: index("idx_sessions_user_id").on(table.userId), + tokenIdx: index("idx_sessions_token").on(table.token), + expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt), + }; +}); + +// Accounts table (for OAuth providers and credentials) +export const accounts = sqliteTable("accounts", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id), + providerId: text("provider_id").notNull(), + providerUserId: text("provider_user_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: integer("expires_at", { mode: "timestamp" }), + password: text("password"), // For credential provider + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql\`(unixepoch())\`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql\`(unixepoch())\`), +}, (table) => { + return { + userIdIdx: index("idx_accounts_user_id").on(table.userId), + providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId), + }; +}); + +// Verification tokens table +export const verificationTokens = sqliteTable("verification_tokens", { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + identifier: text("identifier").notNull(), + type: text("type").notNull(), // email, password-reset, etc + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql\`(unixepoch())\`), +}, (table) => { + return { + tokenIdx: index("idx_verification_tokens_token").on(table.token), + identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier), + }; +}); + +// Future: SSO and OIDC Provider tables will be added when we enable those plugins +`; + +console.log(drizzleSchemaCode); + +// Output information about the schema +console.log("\n\nSummary:"); +console.log("========="); +console.log("- Better Auth will modify the existing 'users' table"); +console.log("- New tables required: sessions, accounts, verification_tokens"); +console.log("\nNote: The 'users' table needs emailVerified field added"); + +tempDb.close(); \ No newline at end of file diff --git a/scripts/migrate-to-better-auth.ts b/scripts/migrate-to-better-auth.ts new file mode 100644 index 0000000..ded3fa9 --- /dev/null +++ b/scripts/migrate-to-better-auth.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env bun + +import { db, users, accounts } from "../src/lib/db"; +import { eq } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Migrate existing users to Better Auth schema + * + * This script: + * 1. Moves existing password hashes from users table to accounts table + * 2. Updates user data to match Better Auth requirements + * 3. Creates credential accounts for existing users + */ + +async function migrateUsers() { + console.log("šŸ”„ Starting user migration to Better Auth..."); + + try { + // Get all existing users + const existingUsers = await db.select().from(users); + + if (existingUsers.length === 0) { + console.log("āœ… No users to migrate"); + return; + } + + console.log(`Found ${existingUsers.length} users to migrate`); + + for (const user of existingUsers) { + console.log(`\nMigrating user: ${user.username} (${user.email})`); + + // Check if user already has a credential account + const existingAccount = await db + .select() + .from(accounts) + .where( + eq(accounts.userId, user.id) && + eq(accounts.providerId, "credential") + ) + .limit(1); + + if (existingAccount.length > 0) { + console.log("āœ“ User already migrated"); + continue; + } + + // Create credential account with existing password hash + await db.insert(accounts).values({ + id: uuidv4(), + userId: user.id, + providerId: "credential", + providerUserId: user.email, // Use email as provider user ID + password: user.password, // Move existing password hash + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }); + + console.log("āœ“ Created credential account"); + + // Update user name field if it's null (Better Auth uses 'name' field) + // Note: Better Auth expects a 'name' field, but we're using username + // This is handled by our additional fields configuration + } + + console.log("\nāœ… User migration completed successfully!"); + + // Summary + const migratedAccounts = await db + .select() + .from(accounts) + .where(eq(accounts.providerId, "credential")); + + console.log(`\nMigration Summary:`); + console.log(`- Total users: ${existingUsers.length}`); + console.log(`- Migrated accounts: ${migratedAccounts.length}`); + + } catch (error) { + console.error("āŒ Migration failed:", error); + process.exit(1); + } +} + +// Run migration +migrateUsers(); \ No newline at end of file diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index d7a82a9..963e551 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuth } from '@/hooks/useAuth'; import { toast, Toaster } from 'sonner'; import { showErrorToast } from '@/lib/utils'; @@ -11,43 +12,29 @@ import { showErrorToast } from '@/lib/utils'; export function LoginForm() { const [isLoading, setIsLoading] = useState(false); + const { login } = useAuth(); async function handleLogin(e: React.FormEvent) { e.preventDefault(); setIsLoading(true); const form = e.currentTarget; const formData = new FormData(form); - const username = formData.get('username') as string | null; + const email = formData.get('email') as string | null; const password = formData.get('password') as string | null; - if (!username || !password) { - toast.error('Please enter both username and password'); + if (!email || !password) { + toast.error('Please enter both email and password'); setIsLoading(false); return; } - const loginData = { username, password }; - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(loginData), - }); - - const data = await response.json(); - - if (response.ok) { - toast.success('Login successful!'); - // Small delay before redirecting to see the success message - setTimeout(() => { - window.location.href = '/'; - }, 1000); - } else { - showErrorToast(data.error || 'Login failed. Please try again.', toast); - } + await login(email, password); + toast.success('Login successful!'); + // Small delay before redirecting to see the success message + setTimeout(() => { + window.location.href = '/dashboard'; + }, 1000); } catch (error) { showErrorToast(error, toast); } finally { @@ -80,16 +67,16 @@ export function LoginForm() {
-
diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx index 53f52f1..6573733 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/components/auth/SignupForm.tsx @@ -5,9 +5,11 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { toast, Toaster } from 'sonner'; import { showErrorToast } from '@/lib/utils'; +import { useAuth } from '@/hooks/useAuth'; export function SignupForm() { const [isLoading, setIsLoading] = useState(false); + const { register } = useAuth(); async function handleSignup(e: React.FormEvent) { e.preventDefault(); @@ -31,28 +33,13 @@ export function SignupForm() { return; } - const signupData = { username, email, password }; - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(signupData), - }); - - const data = await response.json(); - - if (response.ok) { - toast.success('Account created successfully! Redirecting to dashboard...'); - // Small delay before redirecting to see the success message - setTimeout(() => { - window.location.href = '/'; - }, 1500); - } else { - showErrorToast(data.error || 'Failed to create account. Please try again.', toast); - } + await register(username, email, password); + toast.success('Account created successfully! Redirecting to dashboard...'); + // Small delay before redirecting to see the success message + setTimeout(() => { + window.location.href = '/dashboard'; + }, 1500); } catch (error) { showErrorToast(error, toast); } finally { diff --git a/src/hooks/useAuth-legacy.ts b/src/hooks/useAuth-legacy.ts new file mode 100644 index 0000000..01b9432 --- /dev/null +++ b/src/hooks/useAuth-legacy.ts @@ -0,0 +1,147 @@ +import * as React from "react"; +import { + useState, + useEffect, + createContext, + useContext, + type Context, +} from "react"; +import { authApi } from "@/lib/api"; +import type { ExtendedUser } from "@/types/user"; + +interface AuthContextType { + user: ExtendedUser | null; + isLoading: boolean; + error: string | null; + login: (username: string, password: string) => Promise; + register: ( + username: string, + email: string, + password: string + ) => Promise; + logout: () => Promise; + refreshUser: () => Promise; // Added refreshUser function +} + +const AuthContext: Context = createContext< + AuthContextType | undefined +>(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Function to refetch the user data + const refreshUser = async () => { + // not using loading state to keep the ui seamless and refresh the data in bg + // setIsLoading(true); + try { + const user = await authApi.getCurrentUser(); + setUser(user); + } catch (err: any) { + setUser(null); + console.error("Failed to refresh user data", err); + } finally { + // setIsLoading(false); + } + }; + + // Automatically check the user status when the app loads + useEffect(() => { + const checkAuth = async () => { + try { + const user = await authApi.getCurrentUser(); + + console.log("User data fetched:", user); + + setUser(user); + } catch (err: any) { + setUser(null); + + // Redirect user based on error + if (err?.message === "No users found") { + window.location.href = "/signup"; + } else { + window.location.href = "/login"; + } + console.error("Auth check failed", err); + } finally { + setIsLoading(false); + } + }; + + checkAuth(); + }, []); + + const login = async (username: string, password: string) => { + setIsLoading(true); + setError(null); + try { + const user = await authApi.login(username, password); + setUser(user); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + throw err; + } finally { + setIsLoading(false); + } + }; + + const register = async ( + username: string, + email: string, + password: string + ) => { + setIsLoading(true); + setError(null); + try { + const user = await authApi.register(username, email, password); + setUser(user); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + throw err; + } finally { + setIsLoading(false); + } + }; + + const logout = async () => { + setIsLoading(true); + try { + await authApi.logout(); + setUser(null); + window.location.href = "/login"; + } catch (err) { + console.error("Logout error:", err); + } finally { + setIsLoading(false); + } + }; + + // Create the context value with the added refreshUser function + const contextValue = { + user, + isLoading, + error, + login, + register, + logout, + refreshUser, + }; + + // Return the provider with the context value + return React.createElement( + AuthContext.Provider, + { value: contextValue }, + children + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 01b9432..9a78cec 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -6,21 +6,22 @@ import { useContext, type Context, } from "react"; -import { authApi } from "@/lib/api"; -import type { ExtendedUser } from "@/types/user"; +import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client"; +import type { Session, AuthUser } from "@/lib/auth-client"; interface AuthContextType { - user: ExtendedUser | null; + user: AuthUser | null; + session: Session | null; isLoading: boolean; error: string | null; - login: (username: string, password: string) => Promise; + login: (email: string, password: string, username?: string) => Promise; register: ( username: string, email: string, password: string ) => Promise; logout: () => Promise; - refreshUser: () => Promise; // Added refreshUser function + refreshUser: () => Promise; } const AuthContext: Context = createContext< @@ -28,60 +29,53 @@ const AuthContext: Context = createContext< >(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const betterAuthSession = useBetterAuthSession(); const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); - // Function to refetch the user data - const refreshUser = async () => { - // not using loading state to keep the ui seamless and refresh the data in bg - // setIsLoading(true); - try { - const user = await authApi.getCurrentUser(); - setUser(user); - } catch (err: any) { - setUser(null); - console.error("Failed to refresh user data", err); - } finally { - // setIsLoading(false); - } - }; + // Derive user and session from Better Auth hook + const user = betterAuthSession.data?.user || null; + const session = betterAuthSession.data || null; - // Automatically check the user status when the app loads + // Check if this is first load and redirect if needed useEffect(() => { - const checkAuth = async () => { - try { - const user = await authApi.getCurrentUser(); - - console.log("User data fetched:", user); - - setUser(user); - } catch (err: any) { - setUser(null); - - // Redirect user based on error - if (err?.message === "No users found") { - window.location.href = "/signup"; - } else { - window.location.href = "/login"; + const checkFirstUser = async () => { + if (!betterAuthSession.isPending && !user) { + try { + // Check if there are any users in the system + const response = await fetch("/api/auth/check-users"); + if (response.status === 404) { + // No users found, redirect to signup + window.location.href = "/signup"; + } else if (!window.location.pathname.includes("/login")) { + // User not authenticated, redirect to login + window.location.href = "/login"; + } + } catch (err) { + console.error("Failed to check users:", err); } - console.error("Auth check failed", err); - } finally { - setIsLoading(false); } }; - checkAuth(); - }, []); + checkFirstUser(); + }, [betterAuthSession.isPending, user]); - const login = async (username: string, password: string) => { + const login = async (email: string, password: string) => { setIsLoading(true); setError(null); try { - const user = await authApi.login(username, password); - setUser(user); + const result = await authClient.signIn.email({ + email, + password, + callbackURL: "/dashboard", + }); + + if (result.error) { + throw new Error(result.error.message || "Login failed"); + } } catch (err) { - setError(err instanceof Error ? err.message : "Login failed"); + const message = err instanceof Error ? err.message : "Login failed"; + setError(message); throw err; } finally { setIsLoading(false); @@ -96,10 +90,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setIsLoading(true); setError(null); try { - const user = await authApi.register(username, email, password); - setUser(user); + const result = await authClient.signUp.email({ + email, + password, + name: username, // Better Auth uses 'name' field + username, // Also pass username as additional field + callbackURL: "/dashboard", + }); + + if (result.error) { + throw new Error(result.error.message || "Registration failed"); + } } catch (err) { - setError(err instanceof Error ? err.message : "Registration failed"); + const message = err instanceof Error ? err.message : "Registration failed"; + setError(message); throw err; } finally { setIsLoading(false); @@ -109,9 +113,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = async () => { setIsLoading(true); try { - await authApi.logout(); - setUser(null); - window.location.href = "/login"; + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + window.location.href = "/login"; + }, + }, + }); } catch (err) { console.error("Logout error:", err); } finally { @@ -119,10 +127,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; - // Create the context value with the added refreshUser function + const refreshUser = async () => { + // Better Auth automatically handles session refresh + // We can force a refetch if needed + await betterAuthSession.refetch(); + }; + + // Create the context value const contextValue = { - user, - isLoading, + user: user as AuthUser | null, + session, + isLoading: isLoading || betterAuthSession.isPending, error, login, register, @@ -145,3 +160,6 @@ export function useAuth() { } return context; } + +// Export the Better Auth session hook for direct use when needed +export { useBetterAuthSession }; \ No newline at end of file diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..ad44b06 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,22 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + // The base URL is optional when running on the same domain + // Better Auth will use the current domain by default +}); + +// Export commonly used methods for convenience +export const { + signIn, + signUp, + signOut, + useSession, + sendVerificationEmail, + resetPassword, + requestPasswordReset, + getSession +} = authClient; + +// Export types +export type Session = Awaited>["data"]; +export type AuthUser = Session extends { user: infer U } ? U : never; \ No newline at end of file diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts new file mode 100644 index 0000000..1bea257 --- /dev/null +++ b/src/lib/auth-config.ts @@ -0,0 +1,76 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { sso, oidcProvider } from "better-auth/plugins"; +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; + +// Generate or use existing JWT secret +const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET; + +if (!JWT_SECRET) { + throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required"); +} + +// This function will be called with the actual database instance +export function createAuth(db: BunSQLiteDatabase) { + return betterAuth({ + // Database configuration + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, // Our tables use plural names (users, not user) + }), + + // Base URL configuration + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + + // Authentication methods + emailAndPassword: { + enabled: true, + requireEmailVerification: false, // We'll enable this later + sendResetPassword: async ({ user, url, token }, request) => { + // TODO: Implement email sending for password reset + console.log("Password reset requested for:", user.email); + console.log("Reset URL:", url); + }, + }, + + // Session configuration + session: { + cookieName: "better-auth-session", + updateSessionCookieAge: true, + expiresIn: 60 * 60 * 24 * 30, // 30 days + }, + + // User configuration + user: { + additionalFields: { + // We can add custom fields here if needed + }, + }, + + // Plugins for future OIDC/SSO support + plugins: [ + // SSO plugin for OIDC client support + sso({ + provisionUser: async (data) => { + // Custom user provisioning logic for SSO users + console.log("Provisioning SSO user:", data); + return data; + }, + }), + + // OIDC Provider plugin (for future use when we want to be an OIDC provider) + oidcProvider({ + loginPage: "/signin", + consentPage: "/oauth/consent", + metadata: { + issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000", + }, + }), + ], + + // Trusted origins for CORS + trustedOrigins: [ + process.env.BETTER_AUTH_URL || "http://localhost:3000", + ], + }); +} \ No newline at end of file diff --git a/src/lib/auth-oidc-config.example.ts b/src/lib/auth-oidc-config.example.ts new file mode 100644 index 0000000..ab8cb97 --- /dev/null +++ b/src/lib/auth-oidc-config.example.ts @@ -0,0 +1,179 @@ +/** + * Example OIDC/SSO Configuration for Better Auth + * + * This file demonstrates how to enable OIDC and SSO features in Gitea Mirror. + * To use: Copy this file to auth-oidc-config.ts and update the auth.ts import. + */ + +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { sso } from "better-auth/plugins/sso"; +import { oidcProvider } from "better-auth/plugins/oidc"; +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; + +export function createAuthWithOIDC(db: BunSQLiteDatabase) { + return betterAuth({ + // Database configuration + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, + }), + + // Base configuration + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + basePath: "/api/auth", + + // Email/Password authentication + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + }, + + // Session configuration + session: { + cookieName: "better-auth-session", + updateSessionCookieAge: true, + expiresIn: 60 * 60 * 24 * 30, // 30 days + }, + + // User configuration with additional fields + user: { + additionalFields: { + username: { + type: "string", + required: true, + defaultValue: "user", + input: true, + } + }, + }, + + // OAuth2 providers (examples) + socialProviders: { + github: { + enabled: !!process.env.GITHUB_OAUTH_CLIENT_ID, + clientId: process.env.GITHUB_OAUTH_CLIENT_ID!, + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET!, + }, + google: { + enabled: !!process.env.GOOGLE_OAUTH_CLIENT_ID, + clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!, + clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!, + }, + }, + + // Plugins + plugins: [ + // SSO Plugin - For OIDC/SAML client functionality + sso({ + // Auto-provision users from SSO providers + provisionUser: async (data) => { + console.log("Provisioning SSO user:", data.email); + + // Custom logic to set username from email + const username = data.email.split('@')[0]; + + return { + ...data, + username, + }; + }, + + // Organization provisioning for enterprise SSO + organizationProvisioning: { + disabled: false, + defaultRole: "member", + getRole: async (user) => { + // Custom logic to determine user role + // For admin emails, grant admin role + if (user.email?.endsWith('@admin.example.com')) { + return 'admin'; + } + return 'member'; + }, + }, + }), + + // OIDC Provider Plugin - Makes Gitea Mirror an OIDC provider + oidcProvider({ + // Login page for OIDC authentication flow + loginPage: "/login", + + // Consent page for OAuth2 authorization + consentPage: "/oauth/consent", + + // Allow dynamic client registration + allowDynamicClientRegistration: false, + + // OIDC metadata configuration + metadata: { + issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000", + authorization_endpoint: "/api/auth/oauth2/authorize", + token_endpoint: "/api/auth/oauth2/token", + userinfo_endpoint: "/api/auth/oauth2/userinfo", + jwks_uri: "/api/auth/jwks", + }, + + // Additional user info claims + getAdditionalUserInfoClaim: (user, scopes) => { + const claims: Record = {}; + + // Add custom claims based on scopes + if (scopes.includes('profile')) { + claims.username = user.username; + claims.preferred_username = user.username; + } + + if (scopes.includes('gitea')) { + // Add Gitea-specific claims + claims.gitea_admin = false; // Customize based on your logic + claims.gitea_repos = []; // Could fetch user's repositories + } + + return claims; + }, + }), + ], + + // Trusted origins for CORS + trustedOrigins: [ + process.env.BETTER_AUTH_URL || "http://localhost:3000", + // Add your OIDC client domains here + ], + }); +} + +// Environment variables needed: +/* +# OAuth2 Providers (optional) +GITHUB_OAUTH_CLIENT_ID=your-github-client-id +GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret +GOOGLE_OAUTH_CLIENT_ID=your-google-client-id +GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret + +# SSO Configuration (when registering providers) +SSO_PROVIDER_ISSUER=https://idp.example.com +SSO_PROVIDER_CLIENT_ID=your-client-id +SSO_PROVIDER_CLIENT_SECRET=your-client-secret +*/ + +// Example: Registering an SSO provider programmatically +/* +import { authClient } from "./auth-client"; + +// Register corporate SSO +await authClient.sso.register({ + issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + domain: "company.com", + clientId: process.env.AZURE_CLIENT_ID!, + clientSecret: process.env.AZURE_CLIENT_SECRET!, + providerId: "azure-ad", + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + image: "picture", + }, +}); +*/ \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b0ef594 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,64 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "./db"; + +// Generate or use existing JWT secret +const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET; + +if (!JWT_SECRET) { + throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required"); +} + +export const auth = betterAuth({ + // Database configuration + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, // Our tables use plural names (users, not user) + }), + + // Base URL configuration + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + basePath: "/api/auth", // Specify the base path for auth endpoints + + // Authentication methods + emailAndPassword: { + enabled: true, + requireEmailVerification: false, // We'll enable this later + sendResetPassword: async ({ user, url, token }, request) => { + // TODO: Implement email sending for password reset + console.log("Password reset requested for:", user.email); + console.log("Reset URL:", url); + }, + }, + + // Session configuration + session: { + cookieName: "better-auth-session", + updateSessionCookieAge: true, + expiresIn: 60 * 60 * 24 * 30, // 30 days + }, + + // User configuration + user: { + additionalFields: { + // Keep the username field from our existing schema + username: { + type: "string", + required: true, + defaultValue: "user", // Default for migration + input: true, // Allow in signup form + } + }, + }, + + // TODO: Add plugins for SSO and OIDC support in the future + // plugins: [], + + // Trusted origins for CORS + trustedOrigins: [ + process.env.BETTER_AUTH_URL || "http://localhost:3000", + ], +}); + +// Export type for use in other parts of the app +export type Auth = typeof auth; \ No newline at end of file diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 7b62d25..b59889b 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -23,14 +23,14 @@ let sqlite: Database; try { sqlite = new Database(dbPath); console.log("Successfully connected to SQLite database using Bun's native driver"); - - // Run Drizzle migrations if needed - runDrizzleMigrations(); } catch (error) { console.error("Error opening database:", error); throw error; } +// Create drizzle instance with the SQLite client +export const db = drizzle({ client: sqlite }); + /** * Run Drizzle migrations */ @@ -57,8 +57,18 @@ function runDrizzleMigrations() { } } -// Create drizzle instance with the SQLite client -export const db = drizzle({ client: sqlite }); +// Run Drizzle migrations after db is initialized +runDrizzleMigrations(); // Export all table definitions from schema -export { users, events, configs, repositories, mirrorJobs, organizations } from "./schema"; +export { + users, + events, + configs, + repositories, + mirrorJobs, + organizations, + sessions, + accounts, + verificationTokens +} from "./schema"; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 12ca324..cf84227 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -8,6 +8,7 @@ export const userSchema = z.object({ username: z.string(), password: z.string(), email: z.string().email(), + emailVerified: z.boolean().default(false), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }); @@ -215,6 +216,7 @@ export const users = sqliteTable("users", { username: text("username").notNull(), password: text("password").notNull(), email: text("email").notNull(), + emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), @@ -434,6 +436,70 @@ export const organizations = sqliteTable("organizations", { }; }); +// ===== Better Auth Tables ===== + +// Sessions table +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + userId: text("user_id").notNull().references(() => users.id), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}, (table) => { + return { + userIdIdx: index("idx_sessions_user_id").on(table.userId), + tokenIdx: index("idx_sessions_token").on(table.token), + expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt), + }; +}); + +// Accounts table (for OAuth providers and credentials) +export const accounts = sqliteTable("accounts", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id), + providerId: text("provider_id").notNull(), + providerUserId: text("provider_user_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: integer("expires_at", { mode: "timestamp" }), + password: text("password"), // For credential provider + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}, (table) => { + return { + userIdIdx: index("idx_accounts_user_id").on(table.userId), + providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId), + }; +}); + +// Verification tokens table +export const verificationTokens = sqliteTable("verification_tokens", { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + identifier: text("identifier").notNull(), + type: text("type").notNull(), // email, password-reset, etc + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}, (table) => { + return { + tokenIdx: index("idx_verification_tokens_token").on(table.token), + identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier), + }; +}); + // Export type definitions export type User = z.infer; export type Config = z.infer; diff --git a/src/lib/utils/auth-helpers.ts b/src/lib/utils/auth-helpers.ts new file mode 100644 index 0000000..10e2336 --- /dev/null +++ b/src/lib/utils/auth-helpers.ts @@ -0,0 +1,58 @@ +import type { APIRoute, APIContext } from "astro"; +import { auth } from "@/lib/auth"; + +/** + * Get authenticated user from request + * @param request - The request object from Astro API route + * @returns The authenticated user or null if not authenticated + */ +export async function getAuthenticatedUser(request: Request) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + return session ? session.user : null; + } catch (error) { + console.error("Error getting session:", error); + return null; + } +} + +/** + * Require authentication for API routes + * Returns an error response if user is not authenticated + * @param context - The API context from Astro + * @returns Object with user if authenticated, or error response if not + */ +export async function requireAuth(context: APIContext) { + const user = await getAuthenticatedUser(context.request); + + if (!user) { + return { + user: null, + response: new Response( + JSON.stringify({ + success: false, + error: "Unauthorized - Please log in", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ), + }; + } + + return { user, response: null }; +} + +/** + * Get user ID from authenticated session + * @param request - The request object from Astro API route + * @returns The user ID or null if not authenticated + */ +export async function getAuthenticatedUserId(request: Request): Promise { + const user = await getAuthenticatedUser(request); + return user?.id || null; +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 7fa984c..3f7fc45 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,6 +3,7 @@ import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from '. import { startCleanupService, stopCleanupService } from './lib/cleanup-service'; import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager'; import { setupSignalHandlers } from './lib/signal-handlers'; +import { auth } from './lib/auth'; // Flag to track if recovery has been initialized let recoveryInitialized = false; @@ -11,6 +12,25 @@ let cleanupServiceStarted = false; let shutdownManagerInitialized = false; export const onRequest = defineMiddleware(async (context, next) => { + // Handle Better Auth session + try { + const session = await auth.api.getSession({ + headers: context.request.headers, + }); + + if (session) { + context.locals.user = session.user; + context.locals.session = session.session; + } else { + context.locals.user = null; + context.locals.session = null; + } + } catch (error) { + // If there's an error getting the session, set to null + context.locals.user = null; + context.locals.session = null; + } + // Initialize shutdown manager and signal handlers first if (!shutdownManagerInitialized) { try { diff --git a/src/pages/api/auth/[...all].ts b/src/pages/api/auth/[...all].ts new file mode 100644 index 0000000..d4077f4 --- /dev/null +++ b/src/pages/api/auth/[...all].ts @@ -0,0 +1,10 @@ +import { auth } from "@/lib/auth"; +import type { APIRoute } from "astro"; + +export const ALL: APIRoute = async (ctx) => { + // If you want to use rate limiting, make sure to set the 'x-forwarded-for' header + // to the request headers from the context + // ctx.request.headers.set("x-forwarded-for", ctx.clientAddress); + + return auth.handler(ctx.request); +}; \ No newline at end of file diff --git a/src/pages/api/auth/check-users.ts b/src/pages/api/auth/check-users.ts new file mode 100644 index 0000000..f726cdb --- /dev/null +++ b/src/pages/api/auth/check-users.ts @@ -0,0 +1,30 @@ +import type { APIRoute } from "astro"; +import { db, users } from "@/lib/db"; +import { sql } from "drizzle-orm"; + +export const GET: APIRoute = async () => { + try { + const userCountResult = await db + .select({ count: sql`count(*)` }) + .from(users); + + const userCount = userCountResult[0].count; + + if (userCount === 0) { + return new Response(JSON.stringify({ error: "No users found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ userCount }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/legacy-backup/README.md b/src/pages/api/auth/legacy-backup/README.md new file mode 100644 index 0000000..f0bdf3a --- /dev/null +++ b/src/pages/api/auth/legacy-backup/README.md @@ -0,0 +1,13 @@ +# Legacy Auth Routes Backup + +These files are the original authentication routes before migrating to Better Auth. +They are kept here as a reference during the migration process. + +## Migration Notes + +- `index.ts` - Handled user session validation and getting current user +- `login.ts` - Handled user login with email/password +- `logout.ts` - Handled user logout and session cleanup +- `register.ts` - Handled new user registration + +All these endpoints are now handled by Better Auth through the catch-all route `[...all].ts`. \ No newline at end of file diff --git a/src/pages/api/auth/index.ts b/src/pages/api/auth/legacy-backup/index.ts similarity index 100% rename from src/pages/api/auth/index.ts rename to src/pages/api/auth/legacy-backup/index.ts diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/legacy-backup/login.ts similarity index 100% rename from src/pages/api/auth/login.ts rename to src/pages/api/auth/legacy-backup/login.ts diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/legacy-backup/logout.ts similarity index 100% rename from src/pages/api/auth/logout.ts rename to src/pages/api/auth/legacy-backup/logout.ts diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/legacy-backup/register.ts similarity index 100% rename from src/pages/api/auth/register.ts rename to src/pages/api/auth/legacy-backup/register.ts diff --git a/src/pages/api/organizations/[id].ts b/src/pages/api/organizations/[id].ts index 9a3c888..152ccac 100644 --- a/src/pages/api/organizations/[id].ts +++ b/src/pages/api/organizations/[id].ts @@ -2,36 +2,17 @@ import type { APIRoute } from "astro"; import { db, organizations } from "@/lib/db"; import { eq, and } from "drizzle-orm"; import { createSecureErrorResponse } from "@/lib/utils"; -import jwt from "jsonwebtoken"; +import { requireAuth } from "@/lib/utils/auth-helpers"; -const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; - -export const PATCH: APIRoute = async ({ request, params, cookies }) => { +export const PATCH: APIRoute = async (context) => { try { - // Get token from Authorization header or cookies - const authHeader = request.headers.get("Authorization"); - const token = authHeader?.split(" ")[1] || cookies.get("token")?.value; + // Check authentication + const { user, response } = await requireAuth(context); + if (response) return response; - if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } + const userId = user!.id; - // Verify token and get user ID - let userId: string; - try { - const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; - userId = decoded.id; - } catch (error) { - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - const orgId = params.id; + const orgId = context.params.id; if (!orgId) { return new Response(JSON.stringify({ error: "Organization ID is required" }), { status: 400, @@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => { }); } - const body = await request.json(); + const body = await context.request.json(); const { destinationOrg } = body; // Validate that the organization belongs to the user diff --git a/src/pages/api/repositories/[id].ts b/src/pages/api/repositories/[id].ts index b79bcce..debbc07 100644 --- a/src/pages/api/repositories/[id].ts +++ b/src/pages/api/repositories/[id].ts @@ -2,36 +2,17 @@ import type { APIRoute } from "astro"; import { db, repositories } from "@/lib/db"; import { eq, and } from "drizzle-orm"; import { createSecureErrorResponse } from "@/lib/utils"; -import jwt from "jsonwebtoken"; +import { requireAuth } from "@/lib/utils/auth-helpers"; -const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; - -export const PATCH: APIRoute = async ({ request, params, cookies }) => { +export const PATCH: APIRoute = async (context) => { try { - // Get token from Authorization header or cookies - const authHeader = request.headers.get("Authorization"); - const token = authHeader?.split(" ")[1] || cookies.get("token")?.value; + // Check authentication + const { user, response } = await requireAuth(context); + if (response) return response; - if (!token) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } + const userId = user!.id; - // Verify token and get user ID - let userId: string; - try { - const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; - userId = decoded.id; - } catch (error) { - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - const repoId = params.id; + const repoId = context.params.id; if (!repoId) { return new Response(JSON.stringify({ error: "Repository ID is required" }), { status: 400, @@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => { }); } - const body = await request.json(); + const body = await context.request.json(); const { destinationOrg } = body; // Validate that the repository belongs to the user diff --git a/src/pages/index.astro b/src/pages/index.astro index 854fb9f..dfd1253 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,12 +1,13 @@ --- import '../styles/global.css'; import App from '@/components/layout/MainLayout'; -import { db, repositories, mirrorJobs, client } from '@/lib/db'; +import { db, repositories, mirrorJobs, users } from '@/lib/db'; +import { sql } from 'drizzle-orm'; import ThemeScript from '@/components/theme/ThemeScript.astro'; // Check if any users exist in the database -const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); -const userCount = userCountResult.rows[0].count; +const userCountResult = await db.select({ count: sql`count(*)` }).from(users); +const userCount = userCountResult[0]?.count || 0; // Redirect to signup if no users exist if (userCount === 0) { diff --git a/src/pages/login.astro b/src/pages/login.astro index 3cf82a6..9ac2759 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -2,11 +2,14 @@ import '../styles/global.css'; import ThemeScript from '@/components/theme/ThemeScript.astro'; import { LoginForm } from '@/components/auth/LoginForm'; -import { client } from '../lib/db'; +import { db, users } from '@/lib/db'; +import { sql } from 'drizzle-orm'; // Check if any users exist in the database -const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); -const userCount = userCountResult.rows[0].count; +const userCountResult = await db + .select({ count: sql`count(*)` }) + .from(users); +const userCount = userCountResult[0].count; // Redirect to signup if no users exist if (userCount === 0) { diff --git a/src/pages/signup.astro b/src/pages/signup.astro index d7f09d3..71a96ee 100644 --- a/src/pages/signup.astro +++ b/src/pages/signup.astro @@ -2,11 +2,14 @@ import '../styles/global.css'; import ThemeScript from '@/components/theme/ThemeScript.astro'; import { SignupForm } from '@/components/auth/SignupForm'; -import { client } from '../lib/db'; +import { db, users } from '@/lib/db'; +import { sql } from 'drizzle-orm'; // Check if any users exist in the database -const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`); -const userCount = userCountResult.rows[0]?.count; +const userCountResult = await db + .select({ count: sql`count(*)` }) + .from(users); +const userCount = userCountResult[0]?.count; // Redirect to login if users already exist if (userCount !== null && Number(userCount) > 0) {