From 46cf117bdf1373cf50d276ddfe56b87795040e2d Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Thu, 10 Jul 2025 21:44:35 +0530 Subject: [PATCH 01/19] Migrate to Drizzle kit --- MIGRATION_GUIDE.md | 92 +++ bun.lock | 63 +++ drizzle.config.ts | 16 + drizzle/0000_big_xorn.sql | 130 +++++ drizzle/meta/0000_snapshot.json | 955 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 + package.json | 7 + scripts/manage-db.ts | 855 ++++++---------------------- src/lib/db/index.ts | 465 +--------------- src/lib/db/schema.sql | 75 --- src/lib/db/schema.ts | 547 +++++++++++++----- src/pages/api/auth/index.ts | 12 +- 12 files changed, 1872 insertions(+), 1358 deletions(-) create mode 100644 MIGRATION_GUIDE.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_big_xorn.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json delete mode 100644 src/lib/db/schema.sql diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..c4b1ed7 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,92 @@ +# Drizzle Kit Migration Guide + +This project now uses Drizzle Kit for database migrations, providing better schema management and migration tracking. + +## Overview + +- **Database**: SQLite (with preparation for future PostgreSQL migration) +- **ORM**: Drizzle ORM with Drizzle Kit for migrations +- **Schema Location**: `/src/lib/db/schema.ts` +- **Migrations Folder**: `/drizzle` +- **Configuration**: `/drizzle.config.ts` + +## Available Commands + +### Database Management +- `bun run init-db` - Initialize database with all migrations +- `bun run check-db` - Check database status and recent migrations +- `bun run reset-users` - Remove all users and related data +- `bun run cleanup-db` - Remove database files + +### Drizzle Kit Commands +- `bun run db:generate` - Generate new migration files from schema changes +- `bun run db:migrate` - Apply pending migrations to database +- `bun run db:push` - Push schema changes directly (development) +- `bun run db:pull` - Pull schema from database +- `bun run db:check` - Check for migration issues +- `bun run db:studio` - Open Drizzle Studio for database browsing + +## Making Schema Changes + +1. **Update Schema**: Edit `/src/lib/db/schema.ts` +2. **Generate Migration**: Run `bun run db:generate` +3. **Review Migration**: Check the generated SQL in `/drizzle` folder +4. **Apply Migration**: Run `bun run db:migrate` or restart the application + +## Migration Process + +The application automatically runs migrations on startup: +- Checks for pending migrations +- Creates migrations table if needed +- Applies all pending migrations in order +- Tracks migration history + +## Schema Organization + +### Tables +- `users` - User authentication and accounts +- `configs` - GitHub/Gitea configurations +- `repositories` - Repository mirror tracking +- `organizations` - GitHub organizations +- `mirror_jobs` - Job tracking with resilience +- `events` - Real-time event notifications + +### Indexes +All performance-critical indexes are automatically created: +- User lookups +- Repository status queries +- Organization filtering +- Job tracking +- Event channels + +## Future PostgreSQL Migration + +The setup is designed for easy PostgreSQL migration: + +1. Update `drizzle.config.ts`: +```typescript +export default defineConfig({ + dialect: "postgresql", + schema: "./src/lib/db/schema.ts", + out: "./drizzle", + dbCredentials: { + connectionString: process.env.DATABASE_URL, + }, +}); +``` + +2. Update connection in `/src/lib/db/index.ts` +3. Generate new migrations: `bun run db:generate` +4. Apply to PostgreSQL: `bun run db:migrate` + +## Troubleshooting + +### Migration Errors +- Check `/drizzle` folder for migration files +- Verify database permissions +- Review migration SQL for conflicts + +### Schema Conflicts +- Use `bun run db:check` to identify issues +- Review generated migrations before applying +- Keep schema.ts as single source of truth \ No newline at end of file diff --git a/bun.lock b/bun.lock index 3feb12d..db69a4a 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.6.0", + "drizzle-kit": "^0.31.4", "jsdom": "^26.1.0", "tsx": "^4.20.3", "vitest": "^3.2.4", @@ -146,6 +147,8 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], @@ -162,6 +165,10 @@ "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], @@ -630,6 +637,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], @@ -742,6 +751,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], + "drizzle-orm": ["drizzle-orm@0.44.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-zGAqBzWWkVSFjZpwPOrmCrgO++1kZ5H/rZ4qTGeGOe18iXGVJWf3WPfHOVwFIbmi8kHjfJstC6rJomzGx8g/dQ=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], @@ -770,6 +781,8 @@ "esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="], + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1324,6 +1337,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1576,6 +1591,8 @@ "@babel/template/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], @@ -1626,6 +1643,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -1654,6 +1673,50 @@ "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], "boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..ec27a84 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/lib/db/schema.ts", + out: "./drizzle", + dbCredentials: { + url: "./data/gitea-mirror.db", + }, + verbose: true, + strict: true, + migrations: { + table: "__drizzle_migrations", + schema: "main", + }, +}); \ No newline at end of file diff --git a/drizzle/0000_big_xorn.sql b/drizzle/0000_big_xorn.sql new file mode 100644 index 0000000..b3c6a8c --- /dev/null +++ b/drizzle/0000_big_xorn.sql @@ -0,0 +1,130 @@ +CREATE TABLE `configs` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `github_config` text NOT NULL, + `gitea_config` text NOT NULL, + `include` text DEFAULT '["*"]' NOT NULL, + `exclude` text DEFAULT '[]' NOT NULL, + `schedule_config` text NOT NULL, + `cleanup_config` text NOT NULL, + `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 TABLE `events` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `channel` text NOT NULL, + `payload` text NOT NULL, + `read` integer DEFAULT false NOT NULL, + `created_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_events_user_channel` ON `events` (`user_id`,`channel`);--> statement-breakpoint +CREATE INDEX `idx_events_created_at` ON `events` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_events_read` ON `events` (`read`);--> statement-breakpoint +CREATE TABLE `mirror_jobs` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `repository_id` text, + `repository_name` text, + `organization_id` text, + `organization_name` text, + `details` text, + `status` text DEFAULT 'imported' NOT NULL, + `message` text NOT NULL, + `timestamp` integer DEFAULT (unixepoch()) NOT NULL, + `job_type` text DEFAULT 'mirror' NOT NULL, + `batch_id` text, + `total_items` integer, + `completed_items` integer DEFAULT 0, + `item_ids` text, + `completed_item_ids` text DEFAULT '[]', + `in_progress` integer DEFAULT false NOT NULL, + `started_at` integer, + `completed_at` integer, + `last_checkpoint` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_mirror_jobs_user_id` ON `mirror_jobs` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_mirror_jobs_batch_id` ON `mirror_jobs` (`batch_id`);--> statement-breakpoint +CREATE INDEX `idx_mirror_jobs_in_progress` ON `mirror_jobs` (`in_progress`);--> statement-breakpoint +CREATE INDEX `idx_mirror_jobs_job_type` ON `mirror_jobs` (`job_type`);--> statement-breakpoint +CREATE INDEX `idx_mirror_jobs_timestamp` ON `mirror_jobs` (`timestamp`);--> statement-breakpoint +CREATE TABLE `organizations` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `config_id` text NOT NULL, + `name` text NOT NULL, + `avatar_url` text NOT NULL, + `membership_role` text DEFAULT 'member' NOT NULL, + `is_included` integer DEFAULT true NOT NULL, + `destination_org` text, + `status` text DEFAULT 'imported' NOT NULL, + `last_mirrored` integer, + `error_message` text, + `repository_count` integer DEFAULT 0 NOT NULL, + `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, + FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint +CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint +CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint +CREATE TABLE `repositories` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `config_id` text NOT NULL, + `name` text NOT NULL, + `full_name` text NOT NULL, + `url` text NOT NULL, + `clone_url` text NOT NULL, + `owner` text NOT NULL, + `organization` text, + `mirrored_location` text DEFAULT '', + `is_private` integer DEFAULT false NOT NULL, + `is_fork` integer DEFAULT false NOT NULL, + `forked_from` text, + `has_issues` integer DEFAULT false NOT NULL, + `is_starred` integer DEFAULT false NOT NULL, + `is_archived` integer DEFAULT false NOT NULL, + `size` integer DEFAULT 0 NOT NULL, + `has_lfs` integer DEFAULT false NOT NULL, + `has_submodules` integer DEFAULT false NOT NULL, + `language` text, + `description` text, + `default_branch` text NOT NULL, + `visibility` text DEFAULT 'public' NOT NULL, + `status` text DEFAULT 'imported' NOT NULL, + `last_mirrored` integer, + `error_message` text, + `destination_org` 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, + FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_repositories_user_id` ON `repositories` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_repositories_config_id` ON `repositories` (`config_id`);--> statement-breakpoint +CREATE INDEX `idx_repositories_status` ON `repositories` (`status`);--> statement-breakpoint +CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);--> statement-breakpoint +CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);--> statement-breakpoint +CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);--> statement-breakpoint +CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password` text NOT NULL, + `email` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..74f1ffa --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,955 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b963d828-412d-4192-b0aa-3b13b83cfba8", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "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": {} + }, + "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 + }, + "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": {} + } + }, + "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 new file mode 100644 index 0000000..423b007 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1752161775910, + "tag": "0000_big_xorn", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index bd80b70..d7251c1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,12 @@ "check-db": "bun scripts/manage-db.ts check", "fix-db": "bun scripts/manage-db.ts fix", "reset-users": "bun scripts/manage-db.ts reset-users", + "db:generate": "bun drizzle-kit generate", + "db:migrate": "bun drizzle-kit migrate", + "db:push": "bun drizzle-kit push", + "db:pull": "bun drizzle-kit pull", + "db:check": "bun drizzle-kit check", + "db:studio": "bun drizzle-kit studio", "startup-recovery": "bun scripts/startup-recovery.ts", "startup-recovery-force": "bun scripts/startup-recovery.ts --force", "test-recovery": "bun scripts/test-recovery.ts", @@ -88,6 +94,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.6.0", + "drizzle-kit": "^0.31.4", "jsdom": "^26.1.0", "tsx": "^4.20.3", "vitest": "^3.2.4" diff --git a/scripts/manage-db.ts b/scripts/manage-db.ts index f7fa1a3..ae858de 100644 --- a/scripts/manage-db.ts +++ b/scripts/manage-db.ts @@ -1,7 +1,12 @@ import fs from "fs"; import path from "path"; import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { v4 as uuidv4 } from "uuid"; +import { users, configs, repositories, organizations, mirrorJobs, events } from "../src/lib/db/schema"; +import bcrypt from "bcryptjs"; +import { eq } from "drizzle-orm"; // Command line arguments const args = process.argv.slice(2); @@ -13,750 +18,222 @@ if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } -// Database paths -const rootDbFile = path.join(process.cwd(), "gitea-mirror.db"); -const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db"); -const dataDbFile = path.join(dataDir, "gitea-mirror.db"); -const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db"); - // Database path - ensure we use absolute path const dbPath = path.join(dataDir, "gitea-mirror.db"); /** - * Ensure all required tables exist + * Initialize database with migrations */ -async function ensureTablesExist() { - // Create or open the database - const db = new Database(dbPath); - - const requiredTables = [ - "users", - "configs", - "repositories", - "organizations", - "mirror_jobs", - "events", - ]; - - for (const table of requiredTables) { - try { - // Check if table exists - const result = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`).get(); - - if (!result) { - console.warn(`âš ī¸ Table '${table}' is missing. Creating it now...`); - - switch (table) { - case "users": - db.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL, - email TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - break; - case "configs": - db.exec(` - CREATE TABLE configs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - github_config TEXT NOT NULL, - gitea_config TEXT NOT NULL, - include TEXT NOT NULL DEFAULT '["*"]', - exclude TEXT NOT NULL DEFAULT '[]', - schedule_config TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - break; - case "repositories": - db.exec(` - CREATE TABLE repositories ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - full_name TEXT NOT NULL, - url TEXT NOT NULL, - clone_url TEXT NOT NULL, - owner TEXT NOT NULL, - organization TEXT, - mirrored_location TEXT DEFAULT '', - is_private INTEGER NOT NULL DEFAULT 0, - is_fork INTEGER NOT NULL DEFAULT 0, - forked_from TEXT, - has_issues INTEGER NOT NULL DEFAULT 0, - is_starred INTEGER NOT NULL DEFAULT 0, - is_archived INTEGER NOT NULL DEFAULT 0, - size INTEGER NOT NULL DEFAULT 0, - has_lfs INTEGER NOT NULL DEFAULT 0, - has_submodules INTEGER NOT NULL DEFAULT 0, - default_branch TEXT NOT NULL, - visibility TEXT NOT NULL DEFAULT 'public', - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - break; - case "organizations": - db.exec(` - CREATE TABLE organizations ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - avatar_url TEXT NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - is_included INTEGER NOT NULL DEFAULT 1, - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - repository_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - break; - case "mirror_jobs": - db.exec(` - CREATE TABLE mirror_jobs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - repository_id TEXT, - repository_name TEXT, - organization_id TEXT, - organization_name TEXT, - details TEXT, - status TEXT NOT NULL DEFAULT 'imported', - message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- New fields for job resilience - job_type TEXT NOT NULL DEFAULT 'mirror', - batch_id TEXT, - total_items INTEGER, - completed_items INTEGER DEFAULT 0, - item_ids TEXT, -- JSON array as text - completed_item_ids TEXT DEFAULT '[]', -- JSON array as text - in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer - started_at TIMESTAMP, - completed_at TIMESTAMP, - last_checkpoint TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - // Create indexes for better performance - db.exec(` - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp); - `); - break; - case "events": - db.exec(` - CREATE TABLE events ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - channel TEXT NOT NULL, - payload TEXT NOT NULL, - read INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - db.exec(` - CREATE INDEX idx_events_user_channel ON events(user_id, channel); - CREATE INDEX idx_events_created_at ON events(created_at); - CREATE INDEX idx_events_read ON events(read); - `); - break; - } - console.log(`✅ Table '${table}' created successfully.`); - } - } catch (error) { - console.error(`❌ Error checking table '${table}':`, error); - process.exit(1); - } +async function initDatabase() { + console.log("đŸ“Ļ Initializing database..."); + + // Create an empty database file if it doesn't exist + if (!fs.existsSync(dbPath)) { + fs.writeFileSync(dbPath, ""); } - // Migration: Add cleanup_config column to existing configs table + // Create SQLite instance + const sqlite = new Database(dbPath); + const db = drizzle({ client: sqlite }); + + // Run migrations + console.log("🔄 Running migrations..."); try { - const db = new Database(dbPath); - - // Check if cleanup_config column exists - const tableInfo = db.query(`PRAGMA table_info(configs)`).all(); - const hasCleanupConfig = tableInfo.some((column: any) => column.name === 'cleanup_config'); - - if (!hasCleanupConfig) { - console.log("Adding cleanup_config column to configs table..."); - - // Add the column with a default value - const defaultCleanupConfig = JSON.stringify({ - enabled: false, - retentionDays: 7, - lastRun: null, - nextRun: null, - }); - - db.exec(`ALTER TABLE configs ADD COLUMN cleanup_config TEXT NOT NULL DEFAULT '${defaultCleanupConfig}'`); - console.log("✅ cleanup_config column added successfully."); - } + migrate(db, { migrationsFolder: "./drizzle" }); + console.log("✅ Migrations completed successfully"); } catch (error) { - console.error("❌ Error during cleanup_config migration:", error); - // Don't exit here as this is not critical for basic functionality + console.error("❌ Error running migrations:", error); + throw error; } + + sqlite.close(); + console.log("✅ Database initialized successfully"); } /** * Check database status */ async function checkDatabase() { - console.log("Checking database status..."); - - // Check for database files in the root directory (which is incorrect) - if (fs.existsSync(rootDbFile)) { - console.warn( - "âš ī¸ WARNING: Database file found in root directory: gitea-mirror.db" - ); - console.warn("This file should be in the data directory."); - console.warn( - 'Run "bun run manage-db fix" to fix this issue or "bun run cleanup-db" to remove it.' - ); + console.log("🔍 Checking database status..."); + + if (!fs.existsSync(dbPath)) { + console.log("❌ Database does not exist at:", dbPath); + console.log("💡 Run 'bun run init-db' to create the database"); + process.exit(1); } - // Check if database files exist in the data directory (which is correct) - if (fs.existsSync(dataDbFile)) { - console.log( - "✅ Database file found in data directory: data/gitea-mirror.db" - ); - - // Check for users - try { - const db = new Database(dbPath); - - // Check for users - const userCountResult = db.query(`SELECT COUNT(*) as count FROM users`).get(); - const userCount = userCountResult?.count || 0; - - if (userCount === 0) { - console.log("â„šī¸ No users found in the database."); - console.log( - "When you start the application, you will be directed to the signup page" - ); - console.log("to create an initial admin account."); - } else { - console.log(`✅ ${userCount} user(s) found in the database.`); - console.log("The application will show the login page on startup."); - } - - // Check for configurations - const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get(); - const configCount = configCountResult?.count || 0; - - if (configCount === 0) { - console.log("â„šī¸ No configurations found in the database."); - console.log( - "You will need to set up your GitHub and Gitea configurations after login." - ); - } else { - console.log( - `✅ ${configCount} configuration(s) found in the database.` - ); - } - } catch (error) { - console.error("❌ Error connecting to the database:", error); - console.warn( - 'The database file might be corrupted. Consider running "bun run manage-db init" to recreate it.' - ); - } - } else { - console.warn("âš ī¸ WARNING: Database file not found in data directory."); - console.warn('Run "bun run manage-db init" to create it.'); - } -} - -// Database schema updates and migrations have been removed -// since the application is not used by anyone yet - -/** - * Initialize the database - */ -async function initializeDatabase() { - // Check if database already exists first - if (fs.existsSync(dataDbFile)) { - console.log("âš ī¸ Database already exists at data/gitea-mirror.db"); - console.log( - 'If you want to recreate the database, run "bun run cleanup-db" first.' - ); - console.log( - 'Or use "bun run manage-db reset-users" to just remove users without recreating tables.' - ); - - // Check if we can connect to it - try { - const db = new Database(dbPath); - db.query(`SELECT COUNT(*) as count FROM users`).get(); - console.log("✅ Database is valid and accessible."); - return; - } catch (error) { - console.error("❌ Error connecting to the existing database:", error); - console.log( - "The database might be corrupted. Proceeding with reinitialization..." - ); - } - } - - console.log(`Initializing database at ${dbPath}...`); + const sqlite = new Database(dbPath); + const db = drizzle({ client: sqlite }); try { - const db = new Database(dbPath); + // Check tables + const tables = sqlite.query( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).all() as Array<{name: string}>; - // Create tables if they don't exist - db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL, - email TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); + console.log("\n📊 Tables found:"); + for (const table of tables) { + const count = sqlite.query(`SELECT COUNT(*) as count FROM ${table.name}`).get() as {count: number}; + console.log(` - ${table.name}: ${count.count} records`); + } - // NOTE: We no longer create a default admin user - user will create one via signup page + // Check migrations + const migrations = sqlite.query( + "SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 5" + ).all() as Array<{hash: string, created_at: number}>; - db.exec(` - CREATE TABLE IF NOT EXISTS configs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - github_config TEXT NOT NULL, - gitea_config TEXT NOT NULL, - include TEXT NOT NULL DEFAULT '["*"]', - exclude TEXT NOT NULL DEFAULT '[]', - schedule_config TEXT NOT NULL, - cleanup_config TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS repositories ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - full_name TEXT NOT NULL, - url TEXT NOT NULL, - clone_url TEXT NOT NULL, - owner TEXT NOT NULL, - organization TEXT, - mirrored_location TEXT DEFAULT '', - is_private INTEGER NOT NULL DEFAULT 0, - is_fork INTEGER NOT NULL DEFAULT 0, - forked_from TEXT, - has_issues INTEGER NOT NULL DEFAULT 0, - is_starred INTEGER NOT NULL DEFAULT 0, - is_archived INTEGER NOT NULL DEFAULT 0, - size INTEGER NOT NULL DEFAULT 0, - has_lfs INTEGER NOT NULL DEFAULT 0, - has_submodules INTEGER NOT NULL DEFAULT 0, - default_branch TEXT NOT NULL, - visibility TEXT NOT NULL DEFAULT 'public', - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS organizations ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - avatar_url TEXT NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - is_included INTEGER NOT NULL DEFAULT 1, - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - repository_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS mirror_jobs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - repository_id TEXT, - repository_name TEXT, - organization_id TEXT, - organization_name TEXT, - details TEXT, - status TEXT NOT NULL DEFAULT 'imported', - message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - channel TEXT NOT NULL, - payload TEXT NOT NULL, - read INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE INDEX IF NOT EXISTS idx_events_user_channel ON events(user_id, channel); - CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); - CREATE INDEX IF NOT EXISTS idx_events_read ON events(read); - `); - - // Insert default config if none exists - const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get(); - const configCount = configCountResult?.count || 0; - - if (configCount === 0) { - // Get the first user - const firstUserResult = db.query(`SELECT id FROM users LIMIT 1`).get(); - - if (firstUserResult) { - const userId = firstUserResult.id; - const configId = uuidv4(); - const githubConfig = JSON.stringify({ - username: process.env.GITHUB_USERNAME || "", - token: process.env.GITHUB_TOKEN || "", - skipForks: false, - privateRepositories: false, - mirrorIssues: false, - mirrorStarred: true, - useSpecificUser: false, - preserveOrgStructure: true, - skipStarredIssues: false, - }); - const giteaConfig = JSON.stringify({ - url: process.env.GITEA_URL || "", - token: process.env.GITEA_TOKEN || "", - username: process.env.GITEA_USERNAME || "", - organization: "", - visibility: "public", - starredReposOrg: "github", - }); - const include = JSON.stringify(["*"]); - const exclude = JSON.stringify([]); - const scheduleConfig = JSON.stringify({ - enabled: false, - interval: 3600, - lastRun: null, - nextRun: null, - }); - const cleanupConfig = JSON.stringify({ - enabled: false, - retentionDays: 7, - lastRun: null, - nextRun: null, - }); - - const stmt = db.prepare(` - INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, cleanup_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - stmt.run( - configId, - userId, - "Default Configuration", - 1, - githubConfig, - giteaConfig, - include, - exclude, - scheduleConfig, - cleanupConfig, - Date.now(), - Date.now() - ); + if (migrations.length > 0) { + console.log("\n📋 Recent migrations:"); + for (const migration of migrations) { + const date = new Date(migration.created_at); + console.log(` - ${migration.hash} (${date.toLocaleString()})`); } } - console.log("✅ Database initialization completed successfully."); + sqlite.close(); + console.log("\n✅ Database check complete"); } catch (error) { - console.error("❌ Error initializing database:", error); + console.error("❌ Error checking database:", error); + sqlite.close(); process.exit(1); } } /** - * Reset users in the database + * Reset user accounts (development only) */ async function resetUsers() { - console.log(`Resetting users in database at ${dbPath}...`); - - try { - // Check if the database exists - const doesDbExist = fs.existsSync(dbPath); - - if (!doesDbExist) { - console.log( - "❌ Database file doesn't exist. Run 'bun run manage-db init' first to create it." - ); - return; - } - - const db = new Database(dbPath); - - // Count existing users - const userCountResult = db.query(`SELECT COUNT(*) as count FROM users`).get(); - const userCount = userCountResult?.count || 0; - - if (userCount === 0) { - console.log("â„šī¸ No users found in the database. Nothing to reset."); - return; - } - - // Delete all users - db.exec(`DELETE FROM users`); - console.log(`✅ Deleted ${userCount} users from the database.`); - - // Check dependent configurations that need to be removed - const configCountResult = db.query(`SELECT COUNT(*) as count FROM configs`).get(); - const configCount = configCountResult?.count || 0; - - if (configCount > 0) { - db.exec(`DELETE FROM configs`); - console.log(`✅ Deleted ${configCount} configurations.`); - } - - // Check for dependent repositories - const repoCountResult = db.query(`SELECT COUNT(*) as count FROM repositories`).get(); - const repoCount = repoCountResult?.count || 0; - - if (repoCount > 0) { - db.exec(`DELETE FROM repositories`); - console.log(`✅ Deleted ${repoCount} repositories.`); - } - - // Check for dependent organizations - const orgCountResult = db.query(`SELECT COUNT(*) as count FROM organizations`).get(); - const orgCount = orgCountResult?.count || 0; - - if (orgCount > 0) { - db.exec(`DELETE FROM organizations`); - console.log(`✅ Deleted ${orgCount} organizations.`); - } - - // Check for dependent mirror jobs - const jobCountResult = db.query(`SELECT COUNT(*) as count FROM mirror_jobs`).get(); - const jobCount = jobCountResult?.count || 0; - - if (jobCount > 0) { - db.exec(`DELETE FROM mirror_jobs`); - console.log(`✅ Deleted ${jobCount} mirror jobs.`); - } - - console.log( - "✅ Database has been reset. The application will now prompt for a new admin account setup on next run." - ); - } catch (error) { - console.error("❌ Error resetting users:", error); + console.log("đŸ—‘ī¸ Resetting all user accounts..."); + + if (!fs.existsSync(dbPath)) { + console.log("❌ Database does not exist"); process.exit(1); } + + const sqlite = new Database(dbPath); + const db = drizzle({ client: sqlite }); + + try { + // Delete all data in order of foreign key dependencies + await db.delete(events); + await db.delete(mirrorJobs); + await db.delete(repositories); + await db.delete(organizations); + await db.delete(configs); + await db.delete(users); + + console.log("✅ All user accounts and related data have been removed"); + + sqlite.close(); + } catch (error) { + console.error("❌ Error resetting users:", error); + sqlite.close(); + process.exit(1); + } +} + +/** + * Clean up database files + */ +async function cleanupDatabase() { + console.log("🧹 Cleaning up database files..."); + + const filesToRemove = [ + dbPath, + path.join(dataDir, "gitea-mirror-dev.db"), + path.join(process.cwd(), "gitea-mirror.db"), + path.join(process.cwd(), "gitea-mirror-dev.db"), + ]; + + for (const file of filesToRemove) { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log(` - Removed: ${file}`); + } + } + + console.log("✅ Database cleanup complete"); } /** * Fix database location issues */ -async function fixDatabaseIssues() { - console.log("Checking for database issues..."); +async function fixDatabase() { + console.log("🔧 Fixing database location issues..."); + + // Legacy database paths + const rootDbFile = path.join(process.cwd(), "gitea-mirror.db"); + const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db"); + const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db"); - // Check for database files in the root directory + // Check for databases in wrong locations if (fs.existsSync(rootDbFile)) { - console.log("Found database file in root directory: gitea-mirror.db"); - - // If the data directory doesn't have the file, move it there - if (!fs.existsSync(dataDbFile)) { - console.log("Moving database file to data directory..."); - fs.copyFileSync(rootDbFile, dataDbFile); - console.log("Database file moved successfully."); + console.log("📁 Found database in root directory"); + if (!fs.existsSync(dbPath)) { + console.log(" → Moving to data directory..."); + fs.renameSync(rootDbFile, dbPath); + console.log("✅ Database moved successfully"); } else { - console.log( - "Database file already exists in data directory. Checking for differences..." - ); - - // Compare file sizes to see which is newer/larger - const rootStats = fs.statSync(rootDbFile); - const dataStats = fs.statSync(dataDbFile); - - if ( - rootStats.size > dataStats.size || - rootStats.mtime > dataStats.mtime - ) { - console.log( - "Root database file is newer or larger. Backing up data directory file and replacing it..." - ); - fs.copyFileSync(dataDbFile, `${dataDbFile}.backup-${Date.now()}`); - fs.copyFileSync(rootDbFile, dataDbFile); - console.log("Database file replaced successfully."); - } + console.log(" âš ī¸ Database already exists in data directory"); + console.log(" → Keeping existing data directory database"); + fs.unlinkSync(rootDbFile); + console.log(" → Removed root directory database"); } - - // Remove the root file - console.log("Removing database file from root directory..."); - fs.unlinkSync(rootDbFile); - console.log("Root database file removed."); } - // Do the same for dev database + // Clean up dev databases if (fs.existsSync(rootDevDbFile)) { - console.log( - "Found development database file in root directory: gitea-mirror-dev.db" - ); - - // If the data directory doesn't have the file, move it there - if (!fs.existsSync(dataDevDbFile)) { - console.log("Moving development database file to data directory..."); - fs.copyFileSync(rootDevDbFile, dataDevDbFile); - console.log("Development database file moved successfully."); - } else { - console.log( - "Development database file already exists in data directory. Checking for differences..." - ); - - // Compare file sizes to see which is newer/larger - const rootStats = fs.statSync(rootDevDbFile); - const dataStats = fs.statSync(dataDevDbFile); - - if ( - rootStats.size > dataStats.size || - rootStats.mtime > dataStats.mtime - ) { - console.log( - "Root development database file is newer or larger. Backing up data directory file and replacing it..." - ); - fs.copyFileSync(dataDevDbFile, `${dataDevDbFile}.backup-${Date.now()}`); - fs.copyFileSync(rootDevDbFile, dataDevDbFile); - console.log("Development database file replaced successfully."); - } - } - - // Remove the root file - console.log("Removing development database file from root directory..."); fs.unlinkSync(rootDevDbFile); - console.log("Root development database file removed."); + console.log(" → Removed root dev database"); + } + if (fs.existsSync(dataDevDbFile)) { + fs.unlinkSync(dataDevDbFile); + console.log(" → Removed data dev database"); } - // Check if database files exist in the data directory - if (!fs.existsSync(dataDbFile)) { - console.warn( - "âš ī¸ WARNING: Production database file not found in data directory." - ); - console.warn('Run "bun run manage-db init" to create it.'); - } else { - console.log("✅ Production database file found in data directory."); - - // Check if we can connect to the database - try { - // Try to query the database - const db = new Database(dbPath); - db.query(`SELECT 1 FROM sqlite_master LIMIT 1`).get(); - console.log(`✅ Successfully connected to the database.`); - } catch (error) { - console.error("❌ Error connecting to the database:", error); - console.warn( - 'The database file might be corrupted. Consider running "bun run manage-db init" to recreate it.' - ); - } - } - - console.log("Database check completed."); + console.log("✅ Database location fixed"); } /** - * Main function to handle the command + * Auto mode - check and initialize if needed */ -async function main() { - console.log(`Database Management Tool for Gitea Mirror`); - - // Ensure all required tables exist - console.log("Ensuring all required tables exist..."); - await ensureTablesExist(); - - switch (command) { - case "check": - await checkDatabase(); - break; - case "init": - await initializeDatabase(); - break; - case "fix": - await fixDatabaseIssues(); - break; - case "reset-users": - await resetUsers(); - break; - case "auto": - // Auto mode: check, fix, and initialize if needed - console.log("Running in auto mode: check, fix, and initialize if needed"); - await fixDatabaseIssues(); - - if (!fs.existsSync(dataDbFile)) { - await initializeDatabase(); - } else { - await checkDatabase(); - } - break; - default: - console.log(` -Available commands: - check - Check database status - init - Initialize the database (only if it doesn't exist) - fix - Fix database location issues - reset-users - Remove all users and their data - auto - Automatic mode: check, fix, and initialize if needed - -Usage: bun run manage-db [command] -`); +async function autoMode() { + if (!fs.existsSync(dbPath)) { + console.log("đŸ“Ļ Database not found, initializing..."); + await initDatabase(); + } else { + console.log("✅ Database already exists"); + await checkDatabase(); } } -main().catch((error) => { - console.error("Error during database management:", error); - process.exit(1); -}); +// Execute command +switch (command) { + case "init": + await initDatabase(); + break; + case "check": + await checkDatabase(); + break; + case "fix": + await fixDatabase(); + break; + case "reset-users": + await resetUsers(); + break; + case "cleanup": + await cleanupDatabase(); + break; + case "auto": + await autoMode(); + break; + default: + console.log("Available commands:"); + console.log(" init - Initialize database with migrations"); + console.log(" check - Check database status"); + console.log(" fix - Fix database location issues"); + console.log(" reset-users - Remove all users and related data"); + console.log(" cleanup - Remove all database files"); + console.log(" auto - Auto initialize if needed"); + process.exit(1); +} \ No newline at end of file diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 66414b3..7b62d25 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,10 +1,8 @@ -import { z } from "zod"; -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import fs from "fs"; import path from "path"; -import { configSchema } from "./schema"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; // Define the database URL - for development we'll use a local SQLite file const dataDir = path.join(process.cwd(), "data"); @@ -26,464 +24,41 @@ try { sqlite = new Database(dbPath); console.log("Successfully connected to SQLite database using Bun's native driver"); - // Ensure all required tables exist - ensureTablesExist(sqlite); - - // Run migrations - runMigrations(sqlite); + // Run Drizzle migrations if needed + runDrizzleMigrations(); } catch (error) { console.error("Error opening database:", error); throw error; } /** - * Run database migrations + * Run Drizzle migrations */ -function runMigrations(db: Database) { +function runDrizzleMigrations() { try { - // Migration 1: Add destination_org column to organizations table - const orgTableInfo = db.query("PRAGMA table_info(organizations)").all() as Array<{name: string}>; - const hasDestinationOrg = orgTableInfo.some(col => col.name === 'destination_org'); + console.log("🔄 Checking for pending migrations..."); + + // Check if migrations table exists + const migrationsTableExists = sqlite + .query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'") + .get(); - if (!hasDestinationOrg) { - console.log("🔄 Running migration: Adding destination_org column to organizations table"); - db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT"); - console.log("✅ Migration completed: destination_org column added"); + if (!migrationsTableExists) { + console.log("đŸ“Ļ First time setup - running initial migrations..."); } - // Migration 2: Add destination_org column to repositories table - const repoTableInfo = db.query("PRAGMA table_info(repositories)").all() as Array<{name: string}>; - const hasRepoDestinationOrg = repoTableInfo.some(col => col.name === 'destination_org'); - - if (!hasRepoDestinationOrg) { - console.log("🔄 Running migration: Adding destination_org column to repositories table"); - db.exec("ALTER TABLE repositories ADD COLUMN destination_org TEXT"); - console.log("✅ Migration completed: destination_org column added to repositories"); - } + // Run migrations using Drizzle migrate function + migrate(db, { migrationsFolder: "./drizzle" }); + + console.log("✅ Database migrations completed successfully"); } catch (error) { console.error("❌ Error running migrations:", error); - // Don't throw - migrations should be non-breaking - } -} - -/** - * Ensure all required tables exist in the database - */ -function ensureTablesExist(db: Database) { - const requiredTables = [ - "users", - "configs", - "repositories", - "organizations", - "mirror_jobs", - "events", - ]; - - for (const table of requiredTables) { - try { - // Check if table exists - const result = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`).get(); - - if (!result) { - console.warn(`âš ī¸ Table '${table}' is missing. Creating it now...`); - createTable(db, table); - console.log(`✅ Table '${table}' created successfully`); - } - } catch (error) { - console.error(`❌ Error checking/creating table '${table}':`, error); - throw error; - } - } -} - -/** - * Create a specific table with its schema - */ -function createTable(db: Database, tableName: string) { - switch (tableName) { - case "users": - db.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL, - email TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - break; - - case "configs": - db.exec(` - CREATE TABLE configs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - github_config TEXT NOT NULL, - gitea_config TEXT NOT NULL, - include TEXT NOT NULL DEFAULT '["*"]', - exclude TEXT NOT NULL DEFAULT '[]', - schedule_config TEXT NOT NULL, - cleanup_config TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - break; - - case "repositories": - db.exec(` - CREATE TABLE repositories ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - full_name TEXT NOT NULL, - url TEXT NOT NULL, - clone_url TEXT NOT NULL, - owner TEXT NOT NULL, - organization TEXT, - mirrored_location TEXT DEFAULT '', - is_private INTEGER NOT NULL DEFAULT 0, - is_fork INTEGER NOT NULL DEFAULT 0, - forked_from TEXT, - has_issues INTEGER NOT NULL DEFAULT 0, - is_starred INTEGER NOT NULL DEFAULT 0, - language TEXT, - description TEXT, - default_branch TEXT NOT NULL, - visibility TEXT NOT NULL DEFAULT 'public', - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - - // Create indexes for repositories - db.exec(` - CREATE INDEX IF NOT EXISTS idx_repositories_user_id ON repositories(user_id); - CREATE INDEX IF NOT EXISTS idx_repositories_config_id ON repositories(config_id); - CREATE INDEX IF NOT EXISTS idx_repositories_status ON repositories(status); - CREATE INDEX IF NOT EXISTS idx_repositories_owner ON repositories(owner); - CREATE INDEX IF NOT EXISTS idx_repositories_organization ON repositories(organization); - CREATE INDEX IF NOT EXISTS idx_repositories_is_fork ON repositories(is_fork); - CREATE INDEX IF NOT EXISTS idx_repositories_is_starred ON repositories(is_starred); - `); - break; - - case "organizations": - db.exec(` - CREATE TABLE organizations ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - avatar_url TEXT NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - is_included INTEGER NOT NULL DEFAULT 1, - status TEXT NOT NULL DEFAULT 'imported', - last_mirrored INTEGER, - error_message TEXT, - repository_count INTEGER NOT NULL DEFAULT 0, - destination_org TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (config_id) REFERENCES configs(id) - ) - `); - - // Create indexes for organizations - db.exec(` - CREATE INDEX IF NOT EXISTS idx_organizations_user_id ON organizations(user_id); - CREATE INDEX IF NOT EXISTS idx_organizations_config_id ON organizations(config_id); - CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status); - CREATE INDEX IF NOT EXISTS idx_organizations_is_included ON organizations(is_included); - `); - break; - - case "mirror_jobs": - db.exec(` - CREATE TABLE mirror_jobs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - repository_id TEXT, - repository_name TEXT, - organization_id TEXT, - organization_name TEXT, - details TEXT, - status TEXT NOT NULL DEFAULT 'imported', - message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- New fields for job resilience - job_type TEXT NOT NULL DEFAULT 'mirror', - batch_id TEXT, - total_items INTEGER, - completed_items INTEGER DEFAULT 0, - item_ids TEXT, -- JSON array as text - completed_item_ids TEXT DEFAULT '[]', -- JSON array as text - in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer - started_at TIMESTAMP, - completed_at TIMESTAMP, - last_checkpoint TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - // Create indexes for mirror_jobs - db.exec(` - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type); - CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp); - `); - break; - - case "events": - db.exec(` - CREATE TABLE events ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - channel TEXT NOT NULL, - payload TEXT NOT NULL, - read INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - // Create indexes for events - db.exec(` - CREATE INDEX IF NOT EXISTS idx_events_user_channel ON events(user_id, channel); - CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); - CREATE INDEX IF NOT EXISTS idx_events_read ON events(read); - `); - break; - - default: - throw new Error(`Unknown table: ${tableName}`); + throw error; } } // Create drizzle instance with the SQLite client export const db = drizzle({ client: sqlite }); -// Simple async wrapper around SQLite API for compatibility -// This maintains backward compatibility with existing code -export const client = { - async execute(sql: string, params?: any[]) { - try { - const stmt = sqlite.query(sql); - if (/^\s*select/i.test(sql)) { - const rows = stmt.all(params ?? []); - return { rows } as { rows: any[] }; - } - stmt.run(params ?? []); - return { rows: [] } as { rows: any[] }; - } catch (error) { - console.error(`Error executing SQL: ${sql}`, error); - throw error; - } - }, -}; - -// Define the tables -export const users = sqliteTable("users", { - id: text("id").primaryKey(), - username: text("username").notNull(), - password: text("password").notNull(), - email: text("email").notNull(), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), -}); - -// New table for event notifications (replacing Redis pub/sub) -export const events = sqliteTable("events", { - id: text("id").primaryKey(), - userId: text("user_id").notNull().references(() => users.id), - channel: text("channel").notNull(), - payload: text("payload", { mode: "json" }).notNull(), - read: integer("read", { mode: "boolean" }).notNull().default(false), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), -}); - -const githubSchema = configSchema.shape.githubConfig; -const giteaSchema = configSchema.shape.giteaConfig; -const scheduleSchema = configSchema.shape.scheduleConfig; -const cleanupSchema = configSchema.shape.cleanupConfig; - -export const configs = sqliteTable("configs", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - name: text("name").notNull(), - isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), - - githubConfig: text("github_config", { mode: "json" }) - .$type>() - .notNull(), - - giteaConfig: text("gitea_config", { mode: "json" }) - .$type>() - .notNull(), - - include: text("include", { mode: "json" }) - .$type() - .notNull() - .default(["*"]), - - exclude: text("exclude", { mode: "json" }) - .$type() - .notNull() - .default([]), - - scheduleConfig: text("schedule_config", { mode: "json" }) - .$type>() - .notNull(), - - cleanupConfig: text("cleanup_config", { mode: "json" }) - .$type>() - .notNull(), - - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), - - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), -}); - -export const repositories = sqliteTable("repositories", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - configId: text("config_id") - .notNull() - .references(() => configs.id), - name: text("name").notNull(), - fullName: text("full_name").notNull(), - url: text("url").notNull(), - cloneUrl: text("clone_url").notNull(), - owner: text("owner").notNull(), - organization: text("organization"), - mirroredLocation: text("mirrored_location").default(""), - - isPrivate: integer("is_private", { mode: "boolean" }) - .notNull() - .default(false), - isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false), - forkedFrom: text("forked_from"), - - hasIssues: integer("has_issues", { mode: "boolean" }) - .notNull() - .default(false), - isStarred: integer("is_starred", { mode: "boolean" }) - .notNull() - .default(false), - isArchived: integer("is_archived", { mode: "boolean" }) - .notNull() - .default(false), - - size: integer("size").notNull().default(0), - hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false), - hasSubmodules: integer("has_submodules", { mode: "boolean" }) - .notNull() - .default(false), - - defaultBranch: text("default_branch").notNull(), - visibility: text("visibility").notNull().default("public"), - - status: text("status").notNull().default("imported"), - lastMirrored: integer("last_mirrored", { mode: "timestamp" }), - errorMessage: text("error_message"), - - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), -}); - -export const mirrorJobs = sqliteTable("mirror_jobs", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - repositoryId: text("repository_id"), - repositoryName: text("repository_name"), - organizationId: text("organization_id"), - organizationName: text("organization_name"), - details: text("details"), - status: text("status").notNull().default("imported"), - message: text("message").notNull(), - timestamp: integer("timestamp", { mode: "timestamp" }) - .notNull() - .default(new Date()), - - // New fields for job resilience - jobType: text("job_type").notNull().default("mirror"), - batchId: text("batch_id"), - totalItems: integer("total_items"), - completedItems: integer("completed_items").default(0), - itemIds: text("item_ids", { mode: "json" }).$type(), - completedItemIds: text("completed_item_ids", { mode: "json" }).$type().default([]), - inProgress: integer("in_progress", { mode: "boolean" }).notNull().default(false), - startedAt: integer("started_at", { mode: "timestamp" }), - completedAt: integer("completed_at", { mode: "timestamp" }), - lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }), -}); - -export const organizations = sqliteTable("organizations", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - configId: text("config_id") - .notNull() - .references(() => configs.id), - name: text("name").notNull(), - - avatarUrl: text("avatar_url").notNull(), - - membershipRole: text("membership_role").notNull().default("member"), - - isIncluded: integer("is_included", { mode: "boolean" }) - .notNull() - .default(true), - - // Override destination organization for this GitHub org's repos - destinationOrg: text("destination_org"), - - status: text("status").notNull().default("imported"), - lastMirrored: integer("last_mirrored", { mode: "timestamp" }), - errorMessage: text("error_message"), - - repositoryCount: integer("repository_count").notNull().default(0), - - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .notNull() - .default(new Date()), -}); +// Export all table definitions from schema +export { users, events, configs, repositories, mirrorJobs, organizations } from "./schema"; diff --git a/src/lib/db/schema.sql b/src/lib/db/schema.sql deleted file mode 100644 index 264645b..0000000 --- a/src/lib/db/schema.sql +++ /dev/null @@ -1,75 +0,0 @@ --- Users table -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - email TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); - --- Configurations table -CREATE TABLE IF NOT EXISTS configs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT 1, - github_config TEXT NOT NULL, - gitea_config TEXT NOT NULL, - schedule_config TEXT NOT NULL, - include TEXT NOT NULL, - exclude TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - --- Repositories table -CREATE TABLE IF NOT EXISTS repositories ( - id TEXT PRIMARY KEY, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - full_name TEXT NOT NULL, - url TEXT NOT NULL, - is_private BOOLEAN NOT NULL, - is_fork BOOLEAN NOT NULL, - owner TEXT NOT NULL, - organization TEXT, - mirrored_location TEXT DEFAULT '', - has_issues BOOLEAN NOT NULL, - is_starred BOOLEAN NOT NULL, - status TEXT NOT NULL, - error_message TEXT, - last_mirrored DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE -); - --- Organizations table -CREATE TABLE IF NOT EXISTS organizations ( - id TEXT PRIMARY KEY, - config_id TEXT NOT NULL, - name TEXT NOT NULL, - type TEXT NOT NULL, - is_included BOOLEAN NOT NULL, - repository_count INTEGER NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE -); - --- Mirror jobs table -CREATE TABLE IF NOT EXISTS mirror_jobs ( - id TEXT PRIMARY KEY, - config_id TEXT NOT NULL, - repository_id TEXT, - status TEXT NOT NULL, - started_at DATETIME NOT NULL, - completed_at DATETIME, - log TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE, - FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL -); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b9decb5..12ca324 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,182 +1,443 @@ import { z } from "zod"; -import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository"; -import { membershipRoleEnum } from "@/types/organizations"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; -// User schema +// ===== Zod Validation Schemas ===== export const userSchema = z.object({ - id: z.string().uuid().optional(), - username: z.string().min(3), - password: z.string().min(8).optional(), // Hashed password + id: z.string(), + username: z.string(), + password: z.string(), email: z.string().email(), - createdAt: z.date().default(() => new Date()), - updatedAt: z.date().default(() => new Date()), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), }); -export type User = z.infer; +export const githubConfigSchema = z.object({ + owner: z.string(), + type: z.enum(["personal", "organization"]), + token: z.string(), + includeStarred: z.boolean().default(false), + includeForks: z.boolean().default(true), + includeArchived: z.boolean().default(false), + includePrivate: z.boolean().default(true), + includePublic: z.boolean().default(true), + includeOrganizations: z.array(z.string()).default([]), + starredReposOrg: z.string().optional(), + mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).default("preserve"), + defaultOrg: z.string().optional(), +}); + +export const giteaConfigSchema = z.object({ + url: z.string().url(), + token: z.string(), + defaultOwner: z.string(), + mirrorInterval: z.string().default("8h"), + lfs: z.boolean().default(false), + wiki: z.boolean().default(false), + visibility: z + .enum(["public", "private", "limited", "default"]) + .default("default"), + createOrg: z.boolean().default(true), + templateOwner: z.string().optional(), + templateRepo: z.string().optional(), + addTopics: z.boolean().default(true), + topicPrefix: z.string().optional(), + preserveVisibility: z.boolean().default(true), + forkStrategy: z + .enum(["skip", "reference", "full-copy"]) + .default("reference"), +}); + +export const scheduleConfigSchema = z.object({ + enabled: z.boolean().default(false), + interval: z.string().default("0 2 * * *"), + concurrent: z.boolean().default(false), + batchSize: z.number().default(10), + pauseBetweenBatches: z.number().default(5000), + retryAttempts: z.number().default(3), + retryDelay: z.number().default(60000), + timeout: z.number().default(3600000), + autoRetry: z.boolean().default(true), + cleanupBeforeMirror: z.boolean().default(false), + notifyOnFailure: z.boolean().default(true), + notifyOnSuccess: z.boolean().default(false), + logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"), + timezone: z.string().default("UTC"), + onlyMirrorUpdated: z.boolean().default(false), + updateInterval: z.number().default(86400000), + skipRecentlyMirrored: z.boolean().default(true), + recentThreshold: z.number().default(3600000), +}); + +export const cleanupConfigSchema = z.object({ + enabled: z.boolean().default(false), + deleteFromGitea: z.boolean().default(false), + deleteIfNotInGitHub: z.boolean().default(true), + protectedRepos: z.array(z.string()).default([]), + dryRun: z.boolean().default(true), + orphanedRepoAction: z + .enum(["skip", "archive", "delete"]) + .default("archive"), + batchSize: z.number().default(10), + pauseBetweenDeletes: z.number().default(2000), +}); -// Configuration schema export const configSchema = z.object({ - id: z.string().uuid().optional(), - userId: z.string().uuid(), - name: z.string().min(1), + id: z.string(), + userId: z.string(), + name: z.string(), isActive: z.boolean().default(true), - githubConfig: z.object({ - username: z.string().min(1), - token: z.string().optional(), - skipForks: z.boolean().default(false), - privateRepositories: z.boolean().default(false), - mirrorIssues: z.boolean().default(false), - mirrorWiki: z.boolean().default(false), - mirrorStarred: z.boolean().default(false), - useSpecificUser: z.boolean().default(false), - singleRepo: z.string().optional(), - includeOrgs: z.array(z.string()).default([]), - excludeOrgs: z.array(z.string()).default([]), - mirrorPublicOrgs: z.boolean().default(false), - publicOrgs: z.array(z.string()).default([]), - skipStarredIssues: z.boolean().default(false), - }), - giteaConfig: z.object({ - username: z.string().min(1), - url: z.string().url(), - token: z.string().min(1), - organization: z.string().optional(), - visibility: z.enum(["public", "private", "limited"]).default("public"), - starredReposOrg: z.string().default("github"), - preserveOrgStructure: z.boolean().default(false), - mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).optional(), - personalReposOrg: z.string().optional(), // Override destination for personal repos - }), + githubConfig: githubConfigSchema, + giteaConfig: giteaConfigSchema, include: z.array(z.string()).default(["*"]), exclude: z.array(z.string()).default([]), - scheduleConfig: z.object({ - enabled: z.boolean().default(false), - interval: z.number().min(1).default(3600), // in seconds - lastRun: z.date().optional(), - nextRun: z.date().optional(), - }), - cleanupConfig: z.object({ - enabled: z.boolean().default(false), - retentionDays: z.number().min(1).default(604800), // in seconds (default: 7 days) - lastRun: z.date().optional(), - nextRun: z.date().optional(), - }), - createdAt: z.date().default(() => new Date()), - updatedAt: z.date().default(() => new Date()), + scheduleConfig: scheduleConfigSchema, + cleanupConfig: cleanupConfigSchema, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), }); -export type Config = z.infer; - -// Repository schema export const repositorySchema = z.object({ - id: z.string().uuid().optional(), - userId: z.string().uuid().optional(), - configId: z.string().uuid(), - - name: z.string().min(1), - fullName: z.string().min(1), + id: z.string(), + userId: z.string(), + configId: z.string(), + name: z.string(), + fullName: z.string(), url: z.string().url(), cloneUrl: z.string().url(), - - owner: z.string().min(1), - organization: z.string().optional(), - + owner: z.string(), + organization: z.string().optional().nullable(), + mirroredLocation: z.string().default(""), isPrivate: z.boolean().default(false), isForked: z.boolean().default(false), - forkedFrom: z.string().optional(), - + forkedFrom: z.string().optional().nullable(), hasIssues: z.boolean().default(false), isStarred: z.boolean().default(false), isArchived: z.boolean().default(false), - - size: z.number(), + size: z.number().default(0), hasLFS: z.boolean().default(false), hasSubmodules: z.boolean().default(false), - + language: z.string().optional().nullable(), + description: z.string().optional().nullable(), defaultBranch: z.string(), - visibility: repositoryVisibilityEnum.default("public"), - - status: repoStatusEnum.default("imported"), - lastMirrored: z.date().optional(), - errorMessage: z.string().optional(), - - mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored - destinationOrg: z.string().optional(), // Custom destination organization override - - createdAt: z.date().default(() => new Date()), - updatedAt: z.date().default(() => new Date()), + visibility: z.enum(["public", "private", "internal"]).default("public"), + status: z + .enum([ + "imported", + "mirroring", + "mirrored", + "failed", + "skipped", + "deleting", + "deleted", + ]) + .default("imported"), + lastMirrored: z.coerce.date().optional().nullable(), + errorMessage: z.string().optional().nullable(), + destinationOrg: z.string().optional().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), }); -export type Repository = z.infer; - -// Mirror job schema export const mirrorJobSchema = z.object({ - id: z.string().uuid().optional(), - userId: z.string().uuid().optional(), - repositoryId: z.string().uuid().optional(), - repositoryName: z.string().optional(), - organizationId: z.string().uuid().optional(), - organizationName: z.string().optional(), - details: z.string().optional(), - status: repoStatusEnum.default("imported"), + id: z.string(), + userId: z.string(), + repositoryId: z.string().optional().nullable(), + repositoryName: z.string().optional().nullable(), + organizationId: z.string().optional().nullable(), + organizationName: z.string().optional().nullable(), + details: z.string().optional().nullable(), + status: z + .enum([ + "imported", + "mirroring", + "mirrored", + "failed", + "skipped", + "deleting", + "deleted", + ]) + .default("imported"), message: z.string(), - timestamp: z.date().default(() => new Date()), - - // New fields for job resilience - jobType: z.enum(["mirror", "sync", "retry"]).default("mirror"), - batchId: z.string().uuid().optional(), // Group related jobs together - totalItems: z.number().optional(), // Total number of items to process - completedItems: z.number().optional(), // Number of items completed - itemIds: z.array(z.string()).optional(), // IDs of items to process - completedItemIds: z.array(z.string()).optional(), // IDs of completed items - inProgress: z.boolean().default(false), // Whether the job is currently running - startedAt: z.date().optional(), // When the job started - completedAt: z.date().optional(), // When the job completed - lastCheckpoint: z.date().optional(), // Last time progress was saved + timestamp: z.coerce.date(), + jobType: z.enum(["mirror", "cleanup", "import"]).default("mirror"), + batchId: z.string().optional().nullable(), + totalItems: z.number().optional().nullable(), + completedItems: z.number().default(0), + itemIds: z.array(z.string()).optional().nullable(), + completedItemIds: z.array(z.string()).default([]), + inProgress: z.boolean().default(false), + startedAt: z.coerce.date().optional().nullable(), + completedAt: z.coerce.date().optional().nullable(), + lastCheckpoint: z.coerce.date().optional().nullable(), }); -export type MirrorJob = z.infer; - -// Organization schema export const organizationSchema = z.object({ - id: z.string().uuid().optional(), - userId: z.string().uuid().optional(), - configId: z.string().uuid(), - - avatarUrl: z.string().url(), - - name: z.string().min(1), - - membershipRole: membershipRoleEnum.default("member"), - - isIncluded: z.boolean().default(false), - - status: repoStatusEnum.default("imported"), - lastMirrored: z.date().optional(), - errorMessage: z.string().optional(), - + id: z.string(), + userId: z.string(), + configId: z.string(), + name: z.string(), + avatarUrl: z.string(), + membershipRole: z.enum(["admin", "member", "owner"]).default("member"), + isIncluded: z.boolean().default(true), + destinationOrg: z.string().optional().nullable(), + status: z + .enum([ + "imported", + "mirroring", + "mirrored", + "failed", + "skipped", + "deleting", + "deleted", + ]) + .default("imported"), + lastMirrored: z.coerce.date().optional().nullable(), + errorMessage: z.string().optional().nullable(), repositoryCount: z.number().default(0), - publicRepositoryCount: z.number().optional(), - privateRepositoryCount: z.number().optional(), - forkRepositoryCount: z.number().optional(), - - // Override destination organization for this GitHub org's repos - destinationOrg: z.string().optional(), - - createdAt: z.date().default(() => new Date()), - updatedAt: z.date().default(() => new Date()), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), }); -export type Organization = z.infer; - -// Event schema (for SQLite-based pub/sub) export const eventSchema = z.object({ - id: z.string().uuid().optional(), - userId: z.string().uuid(), - channel: z.string().min(1), + id: z.string(), + userId: z.string(), + channel: z.string(), payload: z.any(), read: z.boolean().default(false), - createdAt: z.date().default(() => new Date()), + createdAt: z.coerce.date(), }); -export type Event = z.infer; +// ===== Drizzle Table Definitions ===== + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + username: text("username").notNull(), + password: text("password").notNull(), + email: text("email").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export const events = sqliteTable("events", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + channel: text("channel").notNull(), + payload: text("payload", { mode: "json" }).notNull(), + read: integer("read", { mode: "boolean" }).notNull().default(false), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}, (table) => { + return { + userChannelIdx: index("idx_events_user_channel").on(table.userId, table.channel), + createdAtIdx: index("idx_events_created_at").on(table.createdAt), + readIdx: index("idx_events_read").on(table.read), + }; +}); + +export const configs = sqliteTable("configs", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + + githubConfig: text("github_config", { mode: "json" }) + .$type>() + .notNull(), + + giteaConfig: text("gitea_config", { mode: "json" }) + .$type>() + .notNull(), + + include: text("include", { mode: "json" }) + .$type() + .notNull() + .default(sql`'["*"]'`), + + exclude: text("exclude", { mode: "json" }) + .$type() + .notNull() + .default(sql`'[]'`), + + scheduleConfig: text("schedule_config", { mode: "json" }) + .$type>() + .notNull(), + + cleanupConfig: text("cleanup_config", { mode: "json" }) + .$type>() + .notNull(), + + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export const repositories = sqliteTable("repositories", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + configId: text("config_id") + .notNull() + .references(() => configs.id), + name: text("name").notNull(), + fullName: text("full_name").notNull(), + url: text("url").notNull(), + cloneUrl: text("clone_url").notNull(), + owner: text("owner").notNull(), + organization: text("organization"), + mirroredLocation: text("mirrored_location").default(""), + + isPrivate: integer("is_private", { mode: "boolean" }) + .notNull() + .default(false), + isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false), + forkedFrom: text("forked_from"), + + hasIssues: integer("has_issues", { mode: "boolean" }) + .notNull() + .default(false), + isStarred: integer("is_starred", { mode: "boolean" }) + .notNull() + .default(false), + isArchived: integer("is_archived", { mode: "boolean" }) + .notNull() + .default(false), + + size: integer("size").notNull().default(0), + hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false), + hasSubmodules: integer("has_submodules", { mode: "boolean" }) + .notNull() + .default(false), + + language: text("language"), + description: text("description"), + defaultBranch: text("default_branch").notNull(), + visibility: text("visibility").notNull().default("public"), + + status: text("status").notNull().default("imported"), + lastMirrored: integer("last_mirrored", { mode: "timestamp" }), + errorMessage: text("error_message"), + + destinationOrg: text("destination_org"), + + 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_repositories_user_id").on(table.userId), + configIdIdx: index("idx_repositories_config_id").on(table.configId), + statusIdx: index("idx_repositories_status").on(table.status), + ownerIdx: index("idx_repositories_owner").on(table.owner), + organizationIdx: index("idx_repositories_organization").on(table.organization), + isForkedIdx: index("idx_repositories_is_fork").on(table.isForked), + isStarredIdx: index("idx_repositories_is_starred").on(table.isStarred), + }; +}); + +export const mirrorJobs = sqliteTable("mirror_jobs", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + repositoryId: text("repository_id"), + repositoryName: text("repository_name"), + organizationId: text("organization_id"), + organizationName: text("organization_name"), + details: text("details"), + status: text("status").notNull().default("imported"), + message: text("message").notNull(), + timestamp: integer("timestamp", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + + // Job resilience fields + jobType: text("job_type").notNull().default("mirror"), + batchId: text("batch_id"), + totalItems: integer("total_items"), + completedItems: integer("completed_items").default(0), + itemIds: text("item_ids", { mode: "json" }).$type(), + completedItemIds: text("completed_item_ids", { mode: "json" }) + .$type() + .default(sql`'[]'`), + inProgress: integer("in_progress", { mode: "boolean" }) + .notNull() + .default(false), + startedAt: integer("started_at", { mode: "timestamp" }), + completedAt: integer("completed_at", { mode: "timestamp" }), + lastCheckpoint: integer("last_checkpoint", { mode: "timestamp" }), +}, (table) => { + return { + userIdIdx: index("idx_mirror_jobs_user_id").on(table.userId), + batchIdIdx: index("idx_mirror_jobs_batch_id").on(table.batchId), + inProgressIdx: index("idx_mirror_jobs_in_progress").on(table.inProgress), + jobTypeIdx: index("idx_mirror_jobs_job_type").on(table.jobType), + timestampIdx: index("idx_mirror_jobs_timestamp").on(table.timestamp), + }; +}); + +export const organizations = sqliteTable("organizations", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + configId: text("config_id") + .notNull() + .references(() => configs.id), + name: text("name").notNull(), + + avatarUrl: text("avatar_url").notNull(), + + membershipRole: text("membership_role").notNull().default("member"), + + isIncluded: integer("is_included", { mode: "boolean" }) + .notNull() + .default(true), + + destinationOrg: text("destination_org"), + + status: text("status").notNull().default("imported"), + lastMirrored: integer("last_mirrored", { mode: "timestamp" }), + errorMessage: text("error_message"), + + repositoryCount: integer("repository_count").notNull().default(0), + + 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_organizations_user_id").on(table.userId), + configIdIdx: index("idx_organizations_config_id").on(table.configId), + statusIdx: index("idx_organizations_status").on(table.status), + isIncludedIdx: index("idx_organizations_is_included").on(table.isIncluded), + }; +}); + +// Export type definitions +export type User = z.infer; +export type Config = z.infer; +export type Repository = z.infer; +export type MirrorJob = z.infer; +export type Organization = z.infer; +export type Event = z.infer; \ No newline at end of file diff --git a/src/pages/api/auth/index.ts b/src/pages/api/auth/index.ts index 1c2f936..8eeb62b 100644 --- a/src/pages/api/auth/index.ts +++ b/src/pages/api/auth/index.ts @@ -1,6 +1,6 @@ import type { APIRoute } from "astro"; -import { db, users, configs, client } from "@/lib/db"; -import { eq, and } from "drizzle-orm"; +import { db, users, configs } from "@/lib/db"; +import { eq, and, sql } from "drizzle-orm"; import jwt from "jsonwebtoken"; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; @@ -10,10 +10,10 @@ export const GET: APIRoute = async ({ request, cookies }) => { const token = authHeader?.split(" ")[1] || cookies.get("token")?.value; if (!token) { - 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; if (userCount === 0) { return new Response(JSON.stringify({ error: "No users found" }), { From b8383108720526ab883a6276fdf42cc63a7ffe98 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Thu, 10 Jul 2025 23:15:37 +0530 Subject: [PATCH 02/19] 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) { From 6cfe43932f6eff3c601f8498a6b2b909a182b2e7 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Fri, 11 Jul 2025 00:00:37 +0530 Subject: [PATCH 03/19] Fixing issues with Better Auth --- .env.example | 3 +- docker-compose.alt.yml | 2 +- docker-compose.dev.yml | 2 +- docker-compose.yml | 2 +- docker-entrypoint.sh | 33 +- drizzle/{0000_big_xorn.sql => 0000_init.sql} | 56 +- drizzle/0001_vengeful_whirlwind.sql | 45 - drizzle/meta/0000_snapshot.json | 357 ++++- drizzle/meta/0001_snapshot.json | 1261 ------------------ drizzle/meta/_journal.json | 11 +- scripts/gitea-mirror-lxc-local.sh | 4 +- src/components/auth/LoginForm.tsx | 2 +- src/components/auth/LoginPage.tsx | 10 + src/components/auth/SignupForm.tsx | 22 +- src/components/auth/SignupPage.tsx | 10 + src/hooks/useAuth.ts | 30 +- src/lib/auth-config.ts | 7 - src/lib/auth.ts | 25 +- src/lib/config.ts | 6 +- src/lib/db/schema.ts | 12 +- src/pages/api/auth/debug.ts | 72 + src/pages/login.astro | 4 +- src/pages/signup.astro | 4 +- 23 files changed, 558 insertions(+), 1422 deletions(-) rename drizzle/{0000_big_xorn.sql => 0000_init.sql} (70%) delete mode 100644 drizzle/0001_vengeful_whirlwind.sql delete mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/components/auth/LoginPage.tsx create mode 100644 src/components/auth/SignupPage.tsx create mode 100644 src/pages/api/auth/debug.ts diff --git a/.env.example b/.env.example index 36cae22..2a12a0d 100644 --- a/.env.example +++ b/.env.example @@ -10,9 +10,8 @@ PORT=4321 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 +BETTER_AUTH_URL=http://localhost:4321 # 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/docker-compose.alt.yml b/docker-compose.alt.yml index ac011be..9e6a76e 100644 --- a/docker-compose.alt.yml +++ b/docker-compose.alt.yml @@ -15,7 +15,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 - - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] interval: 30s diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a4824c9..321297e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -66,7 +66,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 - - JWT_SECRET=dev-secret-key + - BETTER_AUTH_SECRET=dev-secret-key # GitHub/Gitea Mirror Config - GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username} - GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token} diff --git a/docker-compose.yml b/docker-compose.yml index 527ca05..56373f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: - DATABASE_URL=file:data/gitea-mirror.db - HOST=0.0.0.0 - PORT=4321 - - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} # GitHub/Gitea Mirror Config - GITHUB_USERNAME=${GITHUB_USERNAME:-} - GITHUB_TOKEN=${GITHUB_TOKEN:-} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2ea977d..e1b1a43 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -52,15 +52,26 @@ if [ "$GITEA_SKIP_TLS_VERIFY" = "true" ]; then export NODE_TLS_REJECT_UNAUTHORIZED=0 fi -# Generate a secure JWT secret if one isn't provided or is using the default value -JWT_SECRET_FILE="/app/data/.jwt_secret" -if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then +# Generate a secure BETTER_AUTH_SECRET if one isn't provided or is using the default value +BETTER_AUTH_SECRET_FILE="/app/data/.better_auth_secret" +JWT_SECRET_FILE="/app/data/.jwt_secret" # Old file for backward compatibility + +if [ "$BETTER_AUTH_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$BETTER_AUTH_SECRET" ]; then # Check if we have a previously generated secret - if [ -f "$JWT_SECRET_FILE" ]; then - echo "Using previously generated JWT secret" - export JWT_SECRET=$(cat "$JWT_SECRET_FILE") + if [ -f "$BETTER_AUTH_SECRET_FILE" ]; then + echo "Using previously generated BETTER_AUTH_SECRET" + export BETTER_AUTH_SECRET=$(cat "$BETTER_AUTH_SECRET_FILE") + # Check for old JWT_SECRET file for backward compatibility + elif [ -f "$JWT_SECRET_FILE" ]; then + echo "Migrating from old JWT_SECRET to BETTER_AUTH_SECRET" + export BETTER_AUTH_SECRET=$(cat "$JWT_SECRET_FILE") + # Save to new file + echo "$BETTER_AUTH_SECRET" > "$BETTER_AUTH_SECRET_FILE" + chmod 600 "$BETTER_AUTH_SECRET_FILE" + # Optionally remove old file after successful migration + rm -f "$JWT_SECRET_FILE" else - echo "Generating a secure random JWT secret" + echo "Generating a secure random BETTER_AUTH_SECRET" # Try to generate a secure random string using OpenSSL if command -v openssl >/dev/null 2>&1; then GENERATED_SECRET=$(openssl rand -hex 32) @@ -69,12 +80,12 @@ if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT echo "OpenSSL not found, using fallback method for random generation" GENERATED_SECRET=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1) fi - export JWT_SECRET="$GENERATED_SECRET" + export BETTER_AUTH_SECRET="$GENERATED_SECRET" # Save the secret to a file for persistence across container restarts - echo "$GENERATED_SECRET" > "$JWT_SECRET_FILE" - chmod 600 "$JWT_SECRET_FILE" + echo "$GENERATED_SECRET" > "$BETTER_AUTH_SECRET_FILE" + chmod 600 "$BETTER_AUTH_SECRET_FILE" fi - echo "JWT_SECRET has been set to a secure random value" + echo "BETTER_AUTH_SECRET has been set to a secure random value" fi diff --git a/drizzle/0000_big_xorn.sql b/drizzle/0000_init.sql similarity index 70% rename from drizzle/0000_big_xorn.sql rename to drizzle/0000_init.sql index b3c6a8c..99809c7 100644 --- a/drizzle/0000_big_xorn.sql +++ b/drizzle/0000_init.sql @@ -1,3 +1,21 @@ +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `user_id` text NOT NULL, + `provider_id` text NOT NULL, + `provider_user_id` text, + `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_account_id` ON `accounts` (`account_id`);--> 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 `configs` ( `id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, @@ -120,11 +138,43 @@ CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);--> statement- CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);--> statement-breakpoint CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);--> statement-breakpoint CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);--> 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 `users` ( `id` text PRIMARY KEY NOT NULL, - `username` text NOT NULL, - `password` text NOT NULL, + `name` text, `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, `created_at` integer DEFAULT (unixepoch()) NOT NULL, - `updated_at` integer DEFAULT (unixepoch()) NOT NULL + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + `username` text ); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> 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`); \ No newline at end of file diff --git a/drizzle/0001_vengeful_whirlwind.sql b/drizzle/0001_vengeful_whirlwind.sql deleted file mode 100644 index be593b1..0000000 --- a/drizzle/0001_vengeful_whirlwind.sql +++ /dev/null @@ -1,45 +0,0 @@ -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/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 74f1ffa..231493d 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,9 +1,135 @@ { "version": "6", "dialect": "sqlite", - "id": "b963d828-412d-4192-b0aa-3b13b83cfba8", + "id": "7782b8ba-bdae-42e8-b8a7-614f8be30a58", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "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": false, + "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_account_id": { + "name": "idx_accounts_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "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": { @@ -887,8 +1013,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "users": { - "name": "users", + "sessions": { + "name": "sessions", "columns": { "id": { "name": "id", @@ -897,27 +1023,41 @@ "notNull": true, "autoincrement": false }, - "username": { - "name": "username", + "token": { + "name": "token", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "password": { - "name": "password", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "email": { - "name": "email", - "type": "text", + "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", @@ -935,7 +1075,202 @@ "default": "(unixepoch())" } }, - "indexes": {}, + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "image": { + "name": "image", + "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())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "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": {}, diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json deleted file mode 100644 index c636152..0000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,1261 +0,0 @@ -{ - "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 3b8a751..1df77d8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "6", - "when": 1752161775910, - "tag": "0000_big_xorn", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1752166860985, - "tag": "0001_vengeful_whirlwind", + "when": 1752171873627, + "tag": "0000_init", "breakpoints": true } ] diff --git a/scripts/gitea-mirror-lxc-local.sh b/scripts/gitea-mirror-lxc-local.sh index 339b62a..2168c1b 100755 --- a/scripts/gitea-mirror-lxc-local.sh +++ b/scripts/gitea-mirror-lxc-local.sh @@ -7,7 +7,7 @@ CONTAINER="gitea-test" IMAGE="ubuntu:22.04" INSTALL_DIR="/opt/gitea-mirror" PORT=4321 -JWT_SECRET="$(openssl rand -hex 32)" +BETTER_AUTH_SECRET="$(openssl rand -hex 32)" BUN_ZIP="/tmp/bun-linux-x64.zip" BUN_URL="https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip" @@ -73,7 +73,7 @@ Environment=NODE_ENV=production Environment=HOST=0.0.0.0 Environment=PORT=$PORT Environment=DATABASE_URL=file:data/gitea-mirror.db -Environment=JWT_SECRET=$JWT_SECRET +Environment=BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET [Install] WantedBy=multi-user.target SERVICE diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 963e551..421f5ab 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -33,7 +33,7 @@ export function LoginForm() { toast.success('Login successful!'); // Small delay before redirecting to see the success message setTimeout(() => { - window.location.href = '/dashboard'; + window.location.href = '/'; }, 1000); } catch (error) { showErrorToast(error, toast); diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..abd8937 --- /dev/null +++ b/src/components/auth/LoginPage.tsx @@ -0,0 +1,10 @@ +import { LoginForm } from './LoginForm'; +import Providers from '@/components/layout/Providers'; + +export function LoginPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx index 6573733..77ee885 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/components/auth/SignupForm.tsx @@ -16,12 +16,11 @@ export function SignupForm() { 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; const confirmPassword = formData.get('confirmPassword') as string | null; - if (!username || !email || !password || !confirmPassword) { + if (!email || !password || !confirmPassword) { toast.error('Please fill in all fields'); setIsLoading(false); return; @@ -34,11 +33,13 @@ export function SignupForm() { } try { + // Derive username from email (part before @) + const username = email.split('@')[0]; 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'; + window.location.href = '/'; }, 1500); } catch (error) { showErrorToast(error, toast); @@ -71,20 +72,6 @@ export function SignupForm() {
-
- - -
diff --git a/src/components/auth/SignupPage.tsx b/src/components/auth/SignupPage.tsx new file mode 100644 index 0000000..c864ea2 --- /dev/null +++ b/src/components/auth/SignupPage.tsx @@ -0,0 +1,10 @@ +import { SignupForm } from './SignupForm'; +import Providers from '@/components/layout/Providers'; + +export function SignupPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 9a78cec..7bab564 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -37,28 +37,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const user = betterAuthSession.data?.user || null; const session = betterAuthSession.data || null; - // Check if this is first load and redirect if needed - useEffect(() => { - 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); - } - } - }; - - checkFirstUser(); - }, [betterAuthSession.isPending, user]); + // Don't do any redirects here - let the pages handle their own redirect logic const login = async (email: string, password: string) => { setIsLoading(true); @@ -67,7 +46,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const result = await authClient.signIn.email({ email, password, - callbackURL: "/dashboard", + callbackURL: "/", }); if (result.error) { @@ -93,9 +72,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const result = await authClient.signUp.email({ email, password, - name: username, // Better Auth uses 'name' field - username, // Also pass username as additional field - callbackURL: "/dashboard", + name: username, // Better Auth uses 'name' field for display name + callbackURL: "/", }); if (result.error) { diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts index 1bea257..13ef64a 100644 --- a/src/lib/auth-config.ts +++ b/src/lib/auth-config.ts @@ -3,13 +3,6 @@ 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({ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b0ef594..d120e5f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,23 +1,22 @@ 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"); -} +import { db, users } from "./db"; +import * as schema from "./db/schema"; +import { eq } from "drizzle-orm"; export const auth = betterAuth({ // Database configuration database: drizzleAdapter(db, { provider: "sqlite", usePlural: true, // Our tables use plural names (users, not user) + schema, // Pass the schema explicitly }), + // Secret for signing tokens + secret: process.env.BETTER_AUTH_SECRET, + // Base URL configuration - baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:4321", basePath: "/api/auth", // Specify the base path for auth endpoints // Authentication methods @@ -30,6 +29,7 @@ export const auth = betterAuth({ console.log("Reset URL:", url); }, }, + // Session configuration session: { @@ -44,9 +44,8 @@ export const auth = betterAuth({ // Keep the username field from our existing schema username: { type: "string", - required: true, - defaultValue: "user", // Default for migration - input: true, // Allow in signup form + required: false, + input: false, // Don't show in signup form - we'll derive from email } }, }, @@ -56,7 +55,7 @@ export const auth = betterAuth({ // Trusted origins for CORS trustedOrigins: [ - process.env.BETTER_AUTH_URL || "http://localhost:3000", + process.env.BETTER_AUTH_URL || "http://localhost:4321", ], }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 8968830..3929b64 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -18,9 +18,9 @@ export const ENV = { return "sqlite://data/gitea-mirror.db"; }, - // JWT secret for authentication - JWT_SECRET: - process.env.JWT_SECRET || "your-secret-key-change-this-in-production", + // Better Auth secret for authentication + BETTER_AUTH_SECRET: + process.env.BETTER_AUTH_SECRET || "your-secret-key-change-this-in-production", // Server host and port HOST: process.env.HOST || "localhost", diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index cf84227..a400b2b 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -213,16 +213,18 @@ export const eventSchema = z.object({ export const users = sqliteTable("users", { id: text("id").primaryKey(), - username: text("username").notNull(), - password: text("password").notNull(), - email: text("email").notNull(), + name: text("name"), + email: text("email").notNull().unique(), emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), + image: text("image"), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), updatedAt: integer("updated_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), + // Custom fields + username: text("username"), }); export const events = sqliteTable("events", { @@ -463,9 +465,10 @@ export const sessions = sqliteTable("sessions", { // Accounts table (for OAuth providers and credentials) export const accounts = sqliteTable("accounts", { id: text("id").primaryKey(), + accountId: text("account_id").notNull(), userId: text("user_id").notNull().references(() => users.id), providerId: text("provider_id").notNull(), - providerUserId: text("provider_user_id").notNull(), + providerUserId: text("provider_user_id"), // Make nullable for email/password auth accessToken: text("access_token"), refreshToken: text("refresh_token"), expiresAt: integer("expires_at", { mode: "timestamp" }), @@ -478,6 +481,7 @@ export const accounts = sqliteTable("accounts", { .default(sql`(unixepoch())`), }, (table) => { return { + accountIdIdx: index("idx_accounts_account_id").on(table.accountId), userIdIdx: index("idx_accounts_user_id").on(table.userId), providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId), }; diff --git a/src/pages/api/auth/debug.ts b/src/pages/api/auth/debug.ts new file mode 100644 index 0000000..5dfdbc6 --- /dev/null +++ b/src/pages/api/auth/debug.ts @@ -0,0 +1,72 @@ +import type { APIRoute } from "astro"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { nanoid } from "nanoid"; + +export const GET: APIRoute = async ({ request }) => { + try { + // Get Better Auth configuration info + const info = { + baseURL: auth.options.baseURL, + basePath: auth.options.basePath, + trustedOrigins: auth.options.trustedOrigins, + emailPasswordEnabled: auth.options.emailAndPassword?.enabled, + userFields: auth.options.user?.additionalFields, + databaseConfig: { + usePlural: true, + provider: "sqlite" + } + }; + + return new Response(JSON.stringify({ + success: true, + config: info + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; + +export const POST: APIRoute = async ({ request }) => { + try { + // Test creating a user directly + const userId = nanoid(); + const now = new Date(); + + await db.insert(users).values({ + id: userId, + email: "test2@example.com", + emailVerified: false, + username: "test2", + // Let the database handle timestamps with defaults + }); + + return new Response(JSON.stringify({ + success: true, + userId, + message: "User created successfully" + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + details: error + }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; \ No newline at end of file diff --git a/src/pages/login.astro b/src/pages/login.astro index 9ac2759..e27a6db 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -1,7 +1,7 @@ --- import '../styles/global.css'; import ThemeScript from '@/components/theme/ThemeScript.astro'; -import { LoginForm } from '@/components/auth/LoginForm'; +import { LoginPage } from '@/components/auth/LoginPage'; import { db, users } from '@/lib/db'; import { sql } from 'drizzle-orm'; @@ -30,7 +30,7 @@ const generator = Astro.generator;
- +
diff --git a/src/pages/signup.astro b/src/pages/signup.astro index 71a96ee..3dcfc4c 100644 --- a/src/pages/signup.astro +++ b/src/pages/signup.astro @@ -1,7 +1,7 @@ --- import '../styles/global.css'; import ThemeScript from '@/components/theme/ThemeScript.astro'; -import { SignupForm } from '@/components/auth/SignupForm'; +import { SignupPage } from '@/components/auth/SignupPage'; import { db, users } from '@/lib/db'; import { sql } from 'drizzle-orm'; @@ -34,7 +34,7 @@ const generator = Astro.generator;

Welcome to Gitea Mirror

Let's set up your administrator account to get started.

- +
From 7cb414c7cb8ca7c82defe5ed803076ebe94306d0 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Fri, 11 Jul 2025 00:01:52 +0530 Subject: [PATCH 04/19] FIxing issues --- src/components/layout/Header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 96e2ef2..53d1162 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -129,9 +129,9 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) { From fad78516ef11dc7163e6bcc0d7f4ac106863be3a Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Fri, 11 Jul 2025 01:04:50 +0530 Subject: [PATCH 05/19] Added SSO and OIDC --- certs/README.md | 337 +++-- docs/SSO-OIDC-SETUP.md | 205 +++ drizzle/0001_polite_exodus.sql | 64 + drizzle/meta/0001_snapshot.json | 1722 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/run-migration.ts | 31 + src/components/auth/LoginForm.tsx | 248 +++- src/components/config/ConfigTabs.tsx | 132 +- src/components/config/SSOSettings.tsx | 634 +++++++++ src/components/oauth/ConsentPage.tsx | 276 ++++ src/hooks/useAuthMethods.ts | 65 + src/lib/auth-client.ts | 6 + src/lib/auth.ts | 40 +- src/lib/db/index.ts | 6 +- src/lib/db/schema.ts | 96 ++ src/lib/utils.ts | 9 + src/pages/api/sso/applications.ts | 176 +++ src/pages/api/sso/discover.ts | 69 + src/pages/api/sso/providers.ts | 152 +++ src/pages/docs/advanced.astro | 467 +++++++ src/pages/docs/architecture.astro | 14 +- src/pages/docs/authentication.astro | 535 ++++++++ src/pages/docs/ca-certificates.astro | 475 +++++++ src/pages/docs/index.astro | 46 +- src/pages/docs/quickstart.astro | 2 +- src/pages/oauth/consent.astro | 28 + 26 files changed, 5598 insertions(+), 244 deletions(-) create mode 100644 docs/SSO-OIDC-SETUP.md create mode 100644 drizzle/0001_polite_exodus.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 scripts/run-migration.ts create mode 100644 src/components/config/SSOSettings.tsx create mode 100644 src/components/oauth/ConsentPage.tsx create mode 100644 src/hooks/useAuthMethods.ts create mode 100644 src/pages/api/sso/applications.ts create mode 100644 src/pages/api/sso/discover.ts create mode 100644 src/pages/api/sso/providers.ts create mode 100644 src/pages/docs/advanced.astro create mode 100644 src/pages/docs/authentication.astro create mode 100644 src/pages/docs/ca-certificates.astro create mode 100644 src/pages/oauth/consent.astro diff --git a/certs/README.md b/certs/README.md index dcf4243..98c0a94 100644 --- a/certs/README.md +++ b/certs/README.md @@ -1,149 +1,236 @@ -# Custom CA Certificate Support +# CA Certificates Configuration -This guide explains how to configure Gitea Mirror to work with self-signed certificates or custom Certificate Authorities (CAs). - -> **📁 This is the certs directory!** Place your `.crt` certificate files directly in this directory and they will be automatically loaded when the Docker container starts. +This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances. ## Overview -When connecting to a Gitea instance that uses self-signed certificates or certificates from a private CA, you need to configure the application to trust these certificates. Gitea Mirror supports mounting custom CA certificates that will be automatically configured for use. +When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates. -## Configuration Steps +## Common SSL/TLS Errors -### 1. Prepare Your CA Certificates +If you encounter any of these errors, you need to configure CA certificates: -You're already in the right place! Simply copy your CA certificate(s) into this `certs` directory with `.crt` extension: +- `UNABLE_TO_VERIFY_LEAF_SIGNATURE` +- `SELF_SIGNED_CERT_IN_CHAIN` +- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` +- `CERT_UNTRUSTED` +- `unable to verify the first certificate` -```bash -# From the project root: -cp /path/to/your/ca-certificate.crt ./certs/ +## Configuration by Deployment Method -# Or if you're already in the certs directory: -cp /path/to/your/ca-certificate.crt . -``` +### Docker -You can add multiple CA certificates - they will all be combined into a single bundle. +#### Method 1: Volume Mount (Recommended) -### 2. Mount Certificates in Docker - -Edit your `docker-compose.yml` file to mount the certificates. You have two options: - -**Option 1: Mount individual certificates from certs directory** -```yaml -services: - gitea-mirror: - # ... other configuration ... - volumes: - - gitea-mirror-data:/app/data - - ./certs:/app/certs:ro # Mount CA certificates directory -``` - -**Option 2: Mount system CA bundle (if your CA is already installed system-wide)** -```yaml -services: - gitea-mirror: - # ... other configuration ... - volumes: - - gitea-mirror-data:/app/data - - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro -``` - -> **Note**: Use Option 2 if you've already added your CA certificate to your system's certificate store using `update-ca-certificates` or similar commands. - -> **System CA Bundle Locations**: -> - Debian/Ubuntu: `/etc/ssl/certs/ca-certificates.crt` -> - RHEL/CentOS/Fedora: `/etc/pki/tls/certs/ca-bundle.crt` -> - Alpine Linux: `/etc/ssl/certs/ca-certificates.crt` -> - macOS: `/etc/ssl/cert.pem` - -### 3. Start the Container - -Start or restart your container: - -```bash -docker-compose up -d -``` - -The container will automatically: -1. Detect any `.crt` files in `/app/certs` (Option 1) OR detect mounted system CA bundle (Option 2) -2. For Option 1: Combine certificates into a CA bundle -3. Configure Node.js to use these certificates via `NODE_EXTRA_CA_CERTS` - -You should see log messages like: - -**For Option 1 (individual certificates):** -``` -Custom CA certificates found, configuring Node.js to use them... -Adding certificate: my-ca.crt -NODE_EXTRA_CA_CERTS set to: /app/certs/ca-bundle.crt -``` - -**For Option 2 (system CA bundle):** -``` -System CA bundle mounted, configuring Node.js to use it... -NODE_EXTRA_CA_CERTS set to: /etc/ssl/certs/ca-certificates.crt -``` - -## Testing & Troubleshooting - -### Disable TLS Verification (Testing Only) - -For testing purposes only, you can disable TLS verification entirely: - -```yaml -environment: - - GITEA_SKIP_TLS_VERIFY=true -``` - -**WARNING**: This is insecure and should never be used in production! - -### Common Issues - -1. **Certificate not recognized**: Ensure your certificate file has a `.crt` extension -2. **Connection still fails**: Check that the certificate is in PEM format -3. **Multiple certificates needed**: Add all required certificates (root and intermediate) to the certs directory - -### Verifying Certificate Loading - -Check the container logs to confirm certificates are loaded: - -```bash -docker-compose logs gitea-mirror | grep "CA certificates" -``` - -## Security Considerations - -- Always use proper CA certificates in production -- Never disable TLS verification in production environments -- Keep your CA certificates secure and limit access to the certs directory -- Regularly update certificates before they expire - -## Example Setup - -Here's a complete example for a self-hosted Gitea with custom CA: - -1. Copy your Gitea server's CA certificate to this directory: +1. Create a certificates directory: ```bash - cp /etc/ssl/certs/my-company-ca.crt ./certs/ + mkdir -p ./certs ``` -2. Update `docker-compose.yml`: +2. Copy your CA certificate(s): + ```bash + cp /path/to/your-ca-cert.crt ./certs/ + ``` + +3. Update `docker-compose.yml`: ```yaml + version: '3.8' services: gitea-mirror: - image: ghcr.io/raylabshq/gitea-mirror:latest + image: raylabs/gitea-mirror:latest volumes: - - gitea-mirror-data:/app/data - - ./certs:/app/certs:ro + - ./data:/app/data + - ./certs:/usr/local/share/ca-certificates:ro environment: - - GITEA_URL=https://gitea.mycompany.local - - GITEA_TOKEN=your-token - # ... other configuration ... + - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt ``` -3. Start the service: +4. Restart the container: ```bash - docker-compose up -d + docker-compose down && docker-compose up -d ``` -The application will now trust your custom CA when connecting to your Gitea instance. \ No newline at end of file +#### Method 2: Custom Docker Image + +Create a `Dockerfile`: + +```dockerfile +FROM raylabs/gitea-mirror:latest + +# Copy CA certificates +COPY ./certs/*.crt /usr/local/share/ca-certificates/ + +# Update CA certificates +RUN update-ca-certificates + +# Set environment variable +ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt +``` + +Build and use: +```bash +docker build -t my-gitea-mirror . +``` + +### Native/Bun + +#### Method 1: Environment Variable + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt +bun run start +``` + +#### Method 2: .env File + +Add to your `.env` file: +``` +NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt +``` + +#### Method 3: System CA Store + +**Ubuntu/Debian:** +```bash +sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/ +sudo update-ca-certificates +``` + +**RHEL/CentOS/Fedora:** +```bash +sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/ +sudo update-ca-trust +``` + +**macOS:** +```bash +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain your-ca-cert.crt +``` + +### LXC Container (Proxmox VE) + +1. Enter the container: + ```bash + pct enter + ``` + +2. Create certificates directory: + ```bash + mkdir -p /usr/local/share/ca-certificates + ``` + +3. Copy your CA certificate: + ```bash + cat > /usr/local/share/ca-certificates/your-ca.crt + ``` + (Paste certificate content and press Ctrl+D) + +4. Update the systemd service: + ```bash + cat >> /etc/systemd/system/gitea-mirror.service << EOF + Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt" + EOF + ``` + +5. Reload and restart: + ```bash + systemctl daemon-reload + systemctl restart gitea-mirror + ``` + +## Multiple CA Certificates + +### Option 1: Bundle Certificates + +```bash +cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt +export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt +``` + +### Option 2: System CA Store + +```bash +# Copy all certificates +cp *.crt /usr/local/share/ca-certificates/ +update-ca-certificates +``` + +## Verification + +### 1. Test Gitea Connection +Use the "Test Connection" button in the Gitea configuration section. + +### 2. Check Logs + +**Docker:** +```bash +docker logs gitea-mirror +``` + +**Native:** +Check terminal output + +**LXC:** +```bash +journalctl -u gitea-mirror -f +``` + +### 3. Manual Certificate Test + +```bash +openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt +``` + +## Best Practices + +1. **Certificate Security** + - Keep CA certificates secure + - Use read-only mounts in Docker + - Limit certificate file permissions + - Regularly update certificates + +2. **Certificate Management** + - Use descriptive certificate filenames + - Document certificate purposes + - Track certificate expiration dates + - Maintain certificate backups + +3. **Production Deployment** + - Use proper SSL certificates when possible + - Consider Let's Encrypt for public instances + - Implement certificate rotation procedures + - Monitor certificate expiration + +## Troubleshooting + +### Certificate not being recognized +- Ensure the certificate is in PEM format +- Check that `NODE_EXTRA_CA_CERTS` points to the correct file +- Restart the application after adding certificates + +### Still getting SSL errors +- Verify the complete certificate chain is included +- Check if intermediate certificates are needed +- Ensure the certificate matches the server hostname + +### Certificate expired +- Check validity: `openssl x509 -in cert.crt -noout -dates` +- Update with new certificate from your CA +- Restart Gitea Mirror after updating + +## Certificate Format + +Certificates must be in PEM format. Example: + +``` +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKl8bUgMdErlMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +[... certificate content ...] +-----END CERTIFICATE----- +``` + +If your certificate is in DER format, convert it: +```bash +openssl x509 -inform der -in certificate.cer -out certificate.crt +``` \ No newline at end of file diff --git a/docs/SSO-OIDC-SETUP.md b/docs/SSO-OIDC-SETUP.md new file mode 100644 index 0000000..e1626fa --- /dev/null +++ b/docs/SSO-OIDC-SETUP.md @@ -0,0 +1,205 @@ +# SSO and OIDC Setup Guide + +This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OIDC) provider functionality in Gitea Mirror. + +## Overview + +Gitea Mirror supports three authentication methods: + +1. **Email & Password** - Traditional authentication (always enabled) +2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers +3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror + +## Configuration + +All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab. + +## Setting up SSO (Single Sign-On) + +SSO allows your users to sign in using external identity providers like Google, Okta, Azure AD, etc. + +### Adding an SSO Provider + +1. Navigate to Configuration → Authentication → SSO Providers +2. Click "Add Provider" +3. Fill in the provider details: + +#### Required Fields + +- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`) +- **Domain**: The email domain for this provider (e.g., `example.com`) +- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`) +- **Client ID**: The OAuth client ID from your provider +- **Client Secret**: The OAuth client secret from your provider + +#### Auto-Discovery + +If your provider supports OIDC discovery, you can: +1. Enter the Issuer URL +2. Click "Discover" +3. The system will automatically fetch the authorization and token endpoints + +#### Manual Configuration + +For providers without discovery support, manually enter: +- **Authorization Endpoint**: The OAuth authorization URL +- **Token Endpoint**: The OAuth token exchange URL +- **JWKS Endpoint**: The JSON Web Key Set URL (optional) +- **UserInfo Endpoint**: The user information endpoint (optional) + +### Redirect URL + +When configuring your SSO provider, use this redirect URL: +``` +https://your-domain.com/api/auth/sso/callback/{provider-id} +``` + +Replace `{provider-id}` with your chosen Provider ID. + +### Example: Google SSO Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new OAuth 2.0 Client ID +3. Add authorized redirect URI: `https://your-domain.com/api/auth/sso/callback/google-sso` +4. In Gitea Mirror: + - Issuer URL: `https://accounts.google.com` + - Domain: `your-company.com` + - Provider ID: `google-sso` + - Client ID: [Your Google Client ID] + - Client Secret: [Your Google Client Secret] + - Click "Discover" to auto-fill endpoints + +### Example: Okta SSO Setup + +1. In Okta Admin Console, create a new OIDC Web Application +2. Set redirect URI: `https://your-domain.com/api/auth/sso/callback/okta-sso` +3. In Gitea Mirror: + - Issuer URL: `https://your-okta-domain.okta.com` + - Domain: `your-company.com` + - Provider ID: `okta-sso` + - Client ID: [Your Okta Client ID] + - Client Secret: [Your Okta Client Secret] + - Click "Discover" to auto-fill endpoints + +## Setting up OIDC Provider + +The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider. + +### Creating OAuth Applications + +1. Navigate to Configuration → Authentication → OAuth Applications +2. Click "Create Application" +3. Fill in the application details: + - **Application Name**: Display name for the application + - **Application Type**: Web, Mobile, or Desktop + - **Redirect URLs**: One or more redirect URLs (one per line) + +4. After creation, you'll receive: + - **Client ID**: Share this with the application + - **Client Secret**: Keep this secure and share only once + +### OIDC Endpoints + +Applications can use these standard OIDC endpoints: + +- **Discovery**: `https://your-domain.com/.well-known/openid-configuration` +- **Authorization**: `https://your-domain.com/api/auth/oauth2/authorize` +- **Token**: `https://your-domain.com/api/auth/oauth2/token` +- **UserInfo**: `https://your-domain.com/api/auth/oauth2/userinfo` +- **JWKS**: `https://your-domain.com/api/auth/jwks` + +### Supported Scopes + +- `openid` - Required, provides user ID +- `profile` - User's name, username, and profile picture +- `email` - User's email address and verification status + +### Example: Configuring Another Application + +For an application to use Gitea Mirror as its OIDC provider: + +```javascript +// Example configuration for another app +const oidcConfig = { + issuer: 'https://gitea-mirror.example.com', + clientId: 'client_xxxxxxxxxxxxx', + clientSecret: 'secret_xxxxxxxxxxxxx', + redirectUri: 'https://myapp.com/auth/callback', + scope: 'openid profile email' +}; +``` + +## User Experience + +### Logging In with SSO + +When SSO is configured: + +1. Users see tabs for "Email" and "SSO" on the login page +2. In the SSO tab, they can: + - Click a specific provider button (if configured) + - Enter their work email to be redirected to the appropriate provider + +### OAuth Consent Flow + +When an application requests authentication: + +1. Users are redirected to Gitea Mirror +2. If not logged in, they authenticate first +3. They see a consent screen showing: + - Application name + - Requested permissions + - Option to approve or deny + +## Security Considerations + +1. **Client Secrets**: Store OAuth client secrets securely +2. **Redirect URLs**: Only add trusted redirect URLs for applications +3. **Scopes**: Applications only receive the data for approved scopes +4. **Token Security**: Access tokens expire and can be revoked + +## Troubleshooting + +### SSO Login Issues + +1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI +2. **"Provider not found" error**: Ensure the provider is properly configured and enabled +3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly + +### OIDC Provider Issues + +1. **Application not found**: Ensure the client ID is correct +2. **Invalid redirect URI**: The redirect URI must match exactly what's configured +3. **Consent not working**: Check browser cookies are enabled + +## Managing Access + +### Revoking SSO Access + +Currently, SSO sessions are managed through the identity provider. To revoke access: +1. Log out of Gitea Mirror +2. Revoke access in your identity provider's settings + +### Disabling OAuth Applications + +To disable an application: +1. Go to Configuration → Authentication → OAuth Applications +2. Find the application +3. Click the delete button + +This immediately prevents the application from authenticating new users. + +## Best Practices + +1. **Use HTTPS**: Always use HTTPS in production for security +2. **Regular Audits**: Periodically review configured SSO providers and OAuth applications +3. **Principle of Least Privilege**: Only grant necessary scopes to applications +4. **Monitor Usage**: Keep track of which applications are accessing your OIDC provider +5. **Secure Storage**: Store client secrets in a secure location, never in code + +## Migration Notes + +If migrating from the previous JWT-based authentication: +- Existing users remain unaffected +- Users can continue using email/password authentication +- SSO can be added as an additional authentication method \ No newline at end of file diff --git a/drizzle/0001_polite_exodus.sql b/drizzle/0001_polite_exodus.sql new file mode 100644 index 0000000..5204a57 --- /dev/null +++ b/drizzle/0001_polite_exodus.sql @@ -0,0 +1,64 @@ +CREATE TABLE `oauth_access_tokens` ( + `id` text PRIMARY KEY NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text, + `access_token_expires_at` integer NOT NULL, + `refresh_token_expires_at` integer, + `client_id` text NOT NULL, + `user_id` text NOT NULL, + `scopes` text NOT NULL, + `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_oauth_access_tokens_access_token` ON `oauth_access_tokens` (`access_token`);--> statement-breakpoint +CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint +CREATE TABLE `oauth_applications` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `client_secret` text NOT NULL, + `name` text NOT NULL, + `redirect_urls` text NOT NULL, + `metadata` text, + `type` text NOT NULL, + `disabled` integer DEFAULT false NOT NULL, + `user_id` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_applications_client_id_unique` ON `oauth_applications` (`client_id`);--> statement-breakpoint +CREATE INDEX `idx_oauth_applications_client_id` ON `oauth_applications` (`client_id`);--> statement-breakpoint +CREATE INDEX `idx_oauth_applications_user_id` ON `oauth_applications` (`user_id`);--> statement-breakpoint +CREATE TABLE `oauth_consent` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `client_id` text NOT NULL, + `scopes` text NOT NULL, + `consent_given` integer NOT NULL, + `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_oauth_consent_user_id` ON `oauth_consent` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_oauth_consent_client_id` ON `oauth_consent` (`client_id`);--> statement-breakpoint +CREATE INDEX `idx_oauth_consent_user_client` ON `oauth_consent` (`user_id`,`client_id`);--> statement-breakpoint +CREATE TABLE `sso_providers` ( + `id` text PRIMARY KEY NOT NULL, + `issuer` text NOT NULL, + `domain` text NOT NULL, + `oidc_config` text NOT NULL, + `user_id` text NOT NULL, + `provider_id` text NOT NULL, + `organization_id` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sso_providers_provider_id_unique` ON `sso_providers` (`provider_id`);--> statement-breakpoint +CREATE INDEX `idx_sso_providers_provider_id` ON `sso_providers` (`provider_id`);--> statement-breakpoint +CREATE INDEX `idx_sso_providers_domain` ON `sso_providers` (`domain`);--> statement-breakpoint +CREATE INDEX `idx_sso_providers_issuer` ON `sso_providers` (`issuer`); \ 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..92545f6 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1722 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4e9ce026-e4e3-4a68-a7f2-37ac7747e2a3", + "prevId": "7782b8ba-bdae-42e8-b8a7-614f8be30a58", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "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": false, + "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_account_id": { + "name": "idx_accounts_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "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": {} + }, + "oauth_access_tokens": { + "name": "oauth_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "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": { + "idx_oauth_access_tokens_access_token": { + "name": "idx_oauth_access_tokens_access_token", + "columns": [ + "access_token" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_user_id": { + "name": "idx_oauth_access_tokens_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_client_id": { + "name": "idx_oauth_access_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_applications": { + "name": "oauth_applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "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": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "idx_oauth_applications_client_id": { + "name": "idx_oauth_applications_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_applications_user_id": { + "name": "idx_oauth_applications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "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 + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "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": { + "idx_oauth_consent_user_id": { + "name": "idx_oauth_consent_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_consent_client_id": { + "name": "idx_oauth_consent_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_consent_user_client": { + "name": "idx_oauth_consent_user_client", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_user_id_users_id_fk": { + "name": "oauth_consent_user_id_users_id_fk", + "tableFrom": "oauth_consent", + "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": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "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 + }, + "organization_id": { + "name": "organization_id", + "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": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + }, + "idx_sso_providers_provider_id": { + "name": "idx_sso_providers_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + }, + "idx_sso_providers_domain": { + "name": "idx_sso_providers_domain", + "columns": [ + "domain" + ], + "isUnique": false + }, + "idx_sso_providers_issuer": { + "name": "idx_sso_providers_issuer", + "columns": [ + "issuer" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "image": { + "name": "image", + "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())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "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 1df77d8..300aa73 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1752171873627, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1752173351102, + "tag": "0001_polite_exodus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/run-migration.ts b/scripts/run-migration.ts new file mode 100644 index 0000000..48a7c04 --- /dev/null +++ b/scripts/run-migration.ts @@ -0,0 +1,31 @@ +import { Database } from "bun:sqlite"; +import { readFileSync } from "fs"; +import path from "path"; + +const dbPath = path.join(process.cwd(), "data/gitea-mirror.db"); +const db = new Database(dbPath); + +// Read the migration file +const migrationPath = path.join(process.cwd(), "drizzle/0001_polite_exodus.sql"); +const migration = readFileSync(migrationPath, "utf-8"); + +// Split by statement-breakpoint and execute each statement +const statements = migration.split("--> statement-breakpoint").map(s => s.trim()).filter(s => s); + +try { + db.run("BEGIN TRANSACTION"); + + for (const statement of statements) { + console.log(`Executing: ${statement.substring(0, 50)}...`); + db.run(statement); + } + + db.run("COMMIT"); + console.log("Migration completed successfully!"); +} catch (error) { + db.run("ROLLBACK"); + console.error("Migration failed:", error); + process.exit(1); +} finally { + db.close(); +} \ No newline at end of file diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 421f5ab..82a6884 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -5,14 +5,27 @@ 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 { useAuthMethods } from '@/hooks/useAuthMethods'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { authClient } from '@/lib/auth-client'; +import { Separator } from '@/components/ui/separator'; import { toast, Toaster } from 'sonner'; import { showErrorToast } from '@/lib/utils'; +import { Loader2, Mail, Globe } from 'lucide-react'; export function LoginForm() { const [isLoading, setIsLoading] = useState(false); + const [ssoEmail, setSsoEmail] = useState(''); const { login } = useAuth(); + const { authMethods, isLoading: isLoadingMethods } = useAuthMethods(); + + // Determine which tab to show by default + const getDefaultTab = () => { + if (authMethods.emailPassword) return 'email'; + if (authMethods.sso.enabled) return 'sso'; + return 'email'; // fallback + }; async function handleLogin(e: React.FormEvent) { e.preventDefault(); @@ -42,6 +55,26 @@ export function LoginForm() { } } + async function handleSSOLogin(domain?: string) { + setIsLoading(true); + try { + if (!domain && !ssoEmail) { + toast.error('Please enter your email or select a provider'); + return; + } + + await authClient.signIn.sso({ + email: ssoEmail || undefined, + domain: domain, + callbackURL: '/', + }); + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsLoading(false); + } + } + return ( <> @@ -63,45 +96,182 @@ export function LoginForm() { Log in to manage your GitHub to Gitea mirroring - - -
-
- - -
-
- - -
+ + {isLoadingMethods ? ( + +
+
- -
- - - + + ) : ( + <> + {/* Show tabs only if multiple auth methods are available */} + {authMethods.sso.enabled && authMethods.emailPassword ? ( + + + + + Email + + + + SSO + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+ + + +
+ {authMethods.sso.providers.length > 0 && ( + <> +
+

+ Sign in with your organization account +

+ {authMethods.sso.providers.map(provider => ( + + ))} +
+ +
+
+ +
+
+ Or +
+
+ + )} + +
+ + setSsoEmail(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + placeholder="Enter your work email" + disabled={isLoading} + /> +

+ We'll redirect you to your organization's SSO provider +

+
+
+
+ + + +
+
+ ) : ( + // Single auth method - show email/password only + <> + +
+
+
+ + +
+
+ + +
+
+
+
+ + + + + )} + + )} +

Don't have an account? Contact your administrator. diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index d6fdbbf..a373a3f 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { GitHubConfigForm } from './GitHubConfigForm'; import { GiteaConfigForm } from './GiteaConfigForm'; import { AutomationSettings } from './AutomationSettings'; +import { SSOSettings } from './SSOSettings'; import type { ConfigApiResponse, GiteaConfig, @@ -20,6 +21,7 @@ import { RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { Skeleton } from '@/components/ui/skeleton'; import { invalidateConfigCache } from '@/hooks/useConfigStatus'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; type ConfigState = { githubConfig: GitHubConfig; @@ -601,65 +603,71 @@ export function ConfigTabs() {

- {/* Content section - Grid layout */} -
- {/* GitHub & Gitea connections - Side by side */} -
- - setConfig(prev => ({ - ...prev, - githubConfig: - typeof update === 'function' - ? update(prev.githubConfig) - : update, - })) - } - mirrorOptions={config.mirrorOptions} - setMirrorOptions={update => - setConfig(prev => ({ - ...prev, - mirrorOptions: - typeof update === 'function' - ? update(prev.mirrorOptions) - : update, - })) - } - advancedOptions={config.advancedOptions} - setAdvancedOptions={update => - setConfig(prev => ({ - ...prev, - advancedOptions: - typeof update === 'function' - ? update(prev.advancedOptions) - : update, - })) - } - onAutoSave={autoSaveGitHubConfig} - onMirrorOptionsAutoSave={autoSaveMirrorOptions} - onAdvancedOptionsAutoSave={autoSaveAdvancedOptions} - isAutoSaving={isAutoSavingGitHub} - /> - - setConfig(prev => ({ - ...prev, - giteaConfig: - typeof update === 'function' - ? update(prev.giteaConfig) - : update, - })) - } - onAutoSave={autoSaveGiteaConfig} - isAutoSaving={isAutoSavingGitea} - githubUsername={config.githubConfig.username} - /> -
+ {/* Content section - Tabs layout */} + + + Connections + Automation + Authentication + - {/* Automation & Maintenance - Full width */} -
+ +
+ + setConfig(prev => ({ + ...prev, + githubConfig: + typeof update === 'function' + ? update(prev.githubConfig) + : update, + })) + } + mirrorOptions={config.mirrorOptions} + setMirrorOptions={update => + setConfig(prev => ({ + ...prev, + mirrorOptions: + typeof update === 'function' + ? update(prev.mirrorOptions) + : update, + })) + } + advancedOptions={config.advancedOptions} + setAdvancedOptions={update => + setConfig(prev => ({ + ...prev, + advancedOptions: + typeof update === 'function' + ? update(prev.advancedOptions) + : update, + })) + } + onAutoSave={autoSaveGitHubConfig} + onMirrorOptionsAutoSave={autoSaveMirrorOptions} + onAdvancedOptionsAutoSave={autoSaveAdvancedOptions} + isAutoSaving={isAutoSavingGitHub} + /> + + setConfig(prev => ({ + ...prev, + giteaConfig: + typeof update === 'function' + ? update(prev.giteaConfig) + : update, + })) + } + onAutoSave={autoSaveGiteaConfig} + isAutoSaving={isAutoSavingGitea} + githubUsername={config.githubConfig.username} + /> +
+
+ + -
-
+ + + + + +
); } diff --git a/src/components/config/SSOSettings.tsx b/src/components/config/SSOSettings.tsx new file mode 100644 index 0000000..e17ce2c --- /dev/null +++ b/src/components/config/SSOSettings.tsx @@ -0,0 +1,634 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Switch } from '@/components/ui/switch'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { apiRequest, showErrorToast } from '@/lib/utils'; +import { toast } from 'sonner'; +import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy } from 'lucide-react'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '../ui/skeleton'; + +interface SSOProvider { + id: string; + issuer: string; + domain: string; + providerId: string; + organizationId?: string; + oidcConfig: { + clientId: string; + clientSecret: string; + authorizationEndpoint: string; + tokenEndpoint: string; + jwksEndpoint: string; + userInfoEndpoint: string; + mapping: { + id: string; + email: string; + emailVerified: string; + name: string; + image: string; + }; + }; + createdAt: string; + updatedAt: string; +} + +interface OAuthApplication { + id: string; + clientId: string; + clientSecret?: string; + name: string; + redirectURLs: string; + type: string; + disabled: boolean; + metadata?: string; + createdAt: string; + updatedAt: string; +} + +export function SSOSettings() { + const [activeTab, setActiveTab] = useState('providers'); + const [providers, setProviders] = useState([]); + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showProviderDialog, setShowProviderDialog] = useState(false); + const [showAppDialog, setShowAppDialog] = useState(false); + const [isDiscovering, setIsDiscovering] = useState(false); + + // Form states for new provider + const [providerForm, setProviderForm] = useState({ + issuer: '', + domain: '', + providerId: '', + clientId: '', + clientSecret: '', + authorizationEndpoint: '', + tokenEndpoint: '', + jwksEndpoint: '', + userInfoEndpoint: '', + }); + + // Form states for new application + const [appForm, setAppForm] = useState({ + name: '', + redirectURLs: '', + type: 'web', + }); + + // Authentication methods state + const [authMethods, setAuthMethods] = useState({ + emailPassword: true, + sso: false, + oidc: false, + }); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + const [providersRes, appsRes] = await Promise.all([ + apiRequest('/sso/providers'), + apiRequest('/sso/applications'), + ]); + setProviders(providersRes); + setApplications(appsRes); + + // Set auth methods based on what's configured + setAuthMethods({ + emailPassword: true, // Always enabled + sso: providersRes.length > 0, + oidc: appsRes.length > 0, + }); + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsLoading(false); + } + }; + + const discoverOIDC = async () => { + if (!providerForm.issuer) { + toast.error('Please enter an issuer URL'); + return; + } + + setIsDiscovering(true); + try { + const discovered = await apiRequest('/sso/discover', { + method: 'POST', + data: { issuer: providerForm.issuer }, + }); + + setProviderForm(prev => ({ + ...prev, + authorizationEndpoint: discovered.authorizationEndpoint || '', + tokenEndpoint: discovered.tokenEndpoint || '', + jwksEndpoint: discovered.jwksEndpoint || '', + userInfoEndpoint: discovered.userInfoEndpoint || '', + domain: discovered.suggestedDomain || prev.domain, + })); + + toast.success('OIDC configuration discovered successfully'); + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsDiscovering(false); + } + }; + + const createProvider = async () => { + try { + const newProvider = await apiRequest('/sso/providers', { + method: 'POST', + data: { + ...providerForm, + mapping: { + id: 'sub', + email: 'email', + emailVerified: 'email_verified', + name: 'name', + image: 'picture', + }, + }, + }); + + setProviders([...providers, newProvider]); + setShowProviderDialog(false); + setProviderForm({ + issuer: '', + domain: '', + providerId: '', + clientId: '', + clientSecret: '', + authorizationEndpoint: '', + tokenEndpoint: '', + jwksEndpoint: '', + userInfoEndpoint: '', + }); + toast.success('SSO provider created successfully'); + + // Enable SSO auth method + setAuthMethods(prev => ({ ...prev, sso: true })); + } catch (error) { + showErrorToast(error, toast); + } + }; + + const deleteProvider = async (id: string) => { + try { + await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' }); + setProviders(providers.filter(p => p.id !== id)); + toast.success('Provider deleted successfully'); + + // Disable SSO if no providers left + if (providers.length === 1) { + setAuthMethods(prev => ({ ...prev, sso: false })); + } + } catch (error) { + showErrorToast(error, toast); + } + }; + + const createApplication = async () => { + try { + const newApp = await apiRequest('/sso/applications', { + method: 'POST', + data: { + ...appForm, + redirectURLs: appForm.redirectURLs.split('\n').filter(url => url.trim()), + }, + }); + + setApplications([...applications, newApp]); + setShowAppDialog(false); + setAppForm({ + name: '', + redirectURLs: '', + type: 'web', + }); + toast.success('OAuth application created successfully'); + + // Enable OIDC auth method + setAuthMethods(prev => ({ ...prev, oidc: true })); + } catch (error) { + showErrorToast(error, toast); + } + }; + + const deleteApplication = async (id: string) => { + try { + await apiRequest(`/sso/applications?id=${id}`, { method: 'DELETE' }); + setApplications(applications.filter(a => a.id !== id)); + toast.success('Application deleted successfully'); + + // Disable OIDC if no applications left + if (applications.length === 1) { + setAuthMethods(prev => ({ ...prev, oidc: false })); + } + } catch (error) { + showErrorToast(error, toast); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Authentication Methods Card */} + + + Authentication Methods + + Choose which authentication methods are available for users + + + +
+
+ +

+ Traditional email and password authentication +

+
+ +
+ + + +
+
+ +

+ Allow users to sign in with external OIDC providers +

+
+ +
+ + + +
+
+ +

+ Allow other applications to authenticate through this app +

+
+ +
+
+
+ + {/* SSO Configuration Tabs */} + + + SSO Providers + OAuth Applications + + + + + +
+
+ SSO Providers + + Configure external OIDC providers for user authentication + +
+ + + + + + + Add SSO Provider + + Configure an external OIDC provider for user authentication + + +
+
+ +
+ setProviderForm(prev => ({ ...prev, issuer: e.target.value }))} + placeholder="https://accounts.google.com" + /> + +
+
+ +
+
+ + setProviderForm(prev => ({ ...prev, domain: e.target.value }))} + placeholder="example.com" + /> +
+
+ + setProviderForm(prev => ({ ...prev, providerId: e.target.value }))} + placeholder="google-sso" + /> +
+
+ +
+
+ + setProviderForm(prev => ({ ...prev, clientId: e.target.value }))} + /> +
+
+ + setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))} + /> +
+
+ +
+ + setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))} + placeholder="https://accounts.google.com/o/oauth2/auth" + /> +
+ +
+ + setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))} + placeholder="https://oauth2.googleapis.com/token" + /> +
+ + + + + Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'} + + +
+ + + + +
+
+
+
+ + {providers.length === 0 ? ( + + + No SSO providers configured. Add a provider to enable SSO authentication. + + + ) : ( +
+ {providers.map(provider => ( + + +
+
+

{provider.providerId}

+

{provider.domain}

+
+ +
+
+ +
+
+

Issuer

+

{provider.issuer}

+
+
+

Client ID

+

{provider.oidcConfig.clientId}

+
+
+
+
+ ))} +
+ )} +
+
+
+ + + + +
+
+ OAuth Applications + + Applications that can authenticate users through this OIDC provider + +
+ + + + + + + Create OAuth Application + + Register a new application that can use this service for authentication + + +
+
+ + setAppForm(prev => ({ ...prev, name: e.target.value }))} + placeholder="My Application" + /> +
+ +
+ + +
+ +
+ +