Compare commits

...

75 Commits

Author SHA1 Message Date
Arunavo Ray
f2c7728394 Release v3.1.0
### Added
- Support for GITHUB_EXCLUDED_ORGS environment variable
- New textarea UI component for configuration forms

### Fixed
- Mirror strategy configuration test failures
- Organization repository routing logic
- Starred repositories organization routing
- SSO and OIDC authentication issues

### Improved
- Organization configuration for repository routing
- Mirror strategy handling in tests
- Authentication error handling

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 16:46:36 +05:30
Arunavo Ray
dcdb06ac39 fixed tests 2025-07-21 15:17:53 +05:30
Arunavo Ray
9fa10dae00 removed unused import 2025-07-21 12:30:04 +05:30
Arunavo Ray
99dd501a52 Added textarea comp 2025-07-21 12:26:31 +05:30
Arunavo Ray
d4aa665873 more SSO and OIDC fixes 2025-07-21 12:09:38 +05:30
Arunavo Ray
0244133e7b Fix: Starred Repos Organization Bug | Organization Repos Routing 2025-07-21 10:39:48 +05:30
ARUNAVO RAY
6ea5e9efb0 Merge pull request #47 from djmango/master
Add GITHUB_EXCLUDED_ORGS support for organization filtering
2025-07-19 21:28:29 +05:30
Sulaiman Khan Ghori
8d7ca8dd8f Add GITHUB_EXCLUDED_ORGS support for organization filtering 2025-07-18 15:58:04 -07:00
Arunavo Ray
8d2919717f Removed old MIgration Guide 2025-07-19 00:36:46 +05:30
Arunavo Ray
1e06e2bd4b Remove Auto Migrate 2025-07-19 00:28:12 +05:30
Arunavo Ray
67080a7ce9 v3.0.1 2025-07-18 23:53:23 +05:30
Arunavo Ray
9d5db86bdf Updates to Org strategy 2025-07-18 09:52:55 +05:30
Arunavo Ray
3458891511 Updates for starred and personal repos 2025-07-18 09:37:38 +05:30
Arunavo Ray
d388f2e691 consistent and distinct colors for status 2025-07-18 08:37:00 +05:30
Arunavo Ray
7bd862606b More fixes 2025-07-18 00:52:03 +05:30
Arunavo Ray
251baeb1aa Fixed Private Repo Issues 2025-07-17 23:46:01 +05:30
Arunavo Ray
e6a31512ac some more fixes 2025-07-17 23:31:45 +05:30
Arunavo Ray
4430625319 updated lockfile 2025-07-17 17:00:21 +05:30
Arunavo Ray
236bef543b Update CHANGELOG for v3.0.0 release
- Add comprehensive v3.0.0 release notes
- Document breaking changes
- Detail new features: token encryption, SSO/OIDC, header auth
- Include migration requirements
2025-07-17 16:55:03 +05:30
Arunavo Ray
03bad9a0c0 Added Note 2025-07-17 16:50:18 +05:30
ARUNAVO RAY
f60ccfc9f1 Merge pull request #44 from RayLabsHQ/v3
V3
2025-07-17 16:26:11 +05:30
Arunavo Ray
cad77320f3 Added Header Authentication 2025-07-17 16:19:56 +05:30
Arunavo Ray
744064f3aa Updated SSO UI 2025-07-17 16:08:12 +05:30
Arunavo Ray
f83711ecd6 Potential security fixes 2025-07-17 13:41:17 +05:30
Arunavo Ray
bde1f7b5d6 Fix tests 2025-07-17 13:05:18 +05:30
Arunavo Ray
39bfb1e2d1 Migration updates 2025-07-17 12:29:53 +05:30
Arunavo Ray
2140f75436 v3 Migration Guide 2025-07-17 12:18:23 +05:30
Arunavo Ray
a5a827c85f updates 2025-07-17 11:44:27 +05:30
ARUNAVO RAY
0af2626201 Create FUNDING.yml 2025-07-17 11:18:04 +05:30
Arunavo Ray
d48981a8c4 Updated docs 2025-07-16 22:51:47 +05:30
Arunavo Ray
948250deef Updated some interfaces to be more future proof 2025-07-16 22:47:33 +05:30
Arunavo Ray
beedbaf9a4 Added Encryptions to All stored token and passwords 2025-07-16 16:02:34 +05:30
Arunavo Ray
7cc4aa87f2 Fix for mirroring issues for starred repositories 2025-07-16 14:02:31 +05:30
Arunavo Ray
ae41c4e2ea Updated README.md 2025-07-16 13:43:07 +05:30
Arunavo Ray
58c63095b1 Added Star History 2025-07-15 08:40:23 +05:30
Arunavo Ray
938a909787 tsc fixes 2025-07-11 01:17:54 +05:30
Arunavo Ray
fad78516ef Added SSO and OIDC 2025-07-11 01:04:50 +05:30
Arunavo Ray
7cb414c7cb FIxing issues 2025-07-11 00:01:52 +05:30
Arunavo Ray
6cfe43932f Fixing issues with Better Auth 2025-07-11 00:00:37 +05:30
Arunavo Ray
b838310872 Added Better Auth 2025-07-10 23:15:37 +05:30
Arunavo Ray
46cf117bdf Migrate to Drizzle kit 2025-07-10 21:44:35 +05:30
Arunavo Ray
9301cc321c Optimised static comp with .astro 2025-07-09 01:01:37 +05:30
Arunavo Ray
9c17e5c240 Moved website from bun to pnpm. 2025-07-09 00:55:28 +05:30
ARUNAVO RAY
a0b9d852bf Merge pull request #42 from RayLabsHQ/www
Added Website
2025-07-09 00:48:47 +05:30
Arunavo Ray
eed26e4368 updates 2025-07-09 00:47:12 +05:30
Arunavo Ray
e4f79720d4 Added simple analytics to website 2025-07-09 00:44:02 +05:30
Arunavo Ray
11dc299f12 Optimsed for mobile 2025-07-09 00:43:04 +05:30
Arunavo Ray
638554c93a mobile opt 2025-07-09 00:37:39 +05:30
Arunavo Ray
d6de431902 seo 2025-07-09 00:37:30 +05:30
Arunavo Ray
a320dc38ad Updated deisgn 2025-07-09 00:34:04 +05:30
Arunavo Ray
97997bd1c0 Minimalist 2025-07-08 23:57:13 +05:30
Arunavo Ray
316d263298 Added few logos 2025-07-08 23:27:59 +05:30
Arunavo Ray
c891b8cf49 Added real data 2025-07-08 22:10:09 +05:30
Arunavo Ray
b55d6a5629 Updated website deisgn 2025-07-08 21:58:45 +05:30
Arunavo Ray
bb1842bc10 Added init website for gitea-mirror 2025-07-08 19:29:26 +05:30
Arunavo Ray
4958b8b9ec Release v2.22.0
### Added
- Comprehensive mobile and responsive design support across the entire application
- New drawer UI component for enhanced mobile navigation
- Mobile-specific layouts for major components
- Mobile screenshots in documentation

### Improved
- Enhanced mobile user experience with optimized layouts
- Updated organization list cards with better mobile responsiveness
- Better touch interaction support throughout the application

### Fixed
- Type definition issues resolved
- Removed unnecessary console.log statements

### Documentation
- Updated README with mobile usage instructions and screenshots
- Added mobile-specific documentation sections
2025-07-08 00:17:19 +05:30
ARUNAVO RAY
aedf0c23b8 Update README.md 2025-07-07 23:50:48 +05:30
ARUNAVO RAY
b4937d1e01 Update README.md 2025-07-07 23:50:16 +05:30
ARUNAVO RAY
498968695f Update README.md 2025-07-07 23:49:42 +05:30
Arunavo Ray
d0e8e754a7 Type fix 2025-07-07 23:37:52 +05:30
Arunavo Ray
c95a501974 Removed some console.logs 2025-07-07 23:12:26 +05:30
Arunavo Ray
fd8f782f34 Updated org list cards 2025-07-07 23:07:22 +05:30
Arunavo Ray
02ff865e4b Updated Screenshots 2025-07-07 23:07:00 +05:30
Arunavo Ray
df9da165c8 Added mobile layout screenshots 2025-07-07 22:52:33 +05:30
ARUNAVO RAY
180f300752 Merge pull request #40 from RayLabsHQ/mobile-layout
Mobile layout Optimised
2025-07-07 22:30:12 +05:30
Arunavo Ray
472f67a6ae Updates to Repository and Org Pages for Responsive Layouts 2025-07-07 22:02:43 +05:30
Arunavo Ray
6270907e70 Updates for mobile 2025-07-07 20:24:09 +05:30
Arunavo Ray
1deaae4d34 More responsive layout updates to Config Page 2025-07-07 19:27:07 +05:30
Arunavo Ray
b984ff9af4 feat: improve mobile layout across components
- Update ActivityLog component for better mobile responsiveness
- Enhance Header layout for mobile devices
- Improve mobile UX in AddOrganizationDialog
- Optimize Organization component mobile display
- Enhance AddRepositoryDialog mobile layout
2025-07-07 18:51:24 +05:30
Arunavo Ray
24bd0aefe6 Added basic responsive layout 2025-07-07 17:34:54 +05:30
Arunavo Ray
6155e39360 docs: document docker-compose.alt.yml for quick start 2025-07-07 16:36:56 +05:30
ARUNAVO RAY
825363eac2 Merge pull request #38 from ryuupendragon/main
Create docker-compose.alt.yml
2025-07-07 16:27:19 +05:30
ARUNAVO RAY
d78c72f448 Merge pull request #39 from RayLabsHQ/custom-ca-cert
feat: add custom CA certificate support (v2.21.0)
2025-07-07 16:26:49 +05:30
Arunavo Ray
9d7cb0f372 feat: add custom CA certificate support
- Add support for custom CA certificates in Docker setup
- Two mounting options: individual certs or system CA bundle
- Automatic detection and configuration via NODE_EXTRA_CA_CERTS
- Enhanced documentation with setup guide in certs/README.md
- Added ca-certificates package to Alpine base image
- Updated docker-compose with clear volume mount examples
- Bump version to 2.21.0
2025-07-07 16:20:24 +05:30
Ryuu Pendragon
19a252b67c Create docker-compose.alt.yml
Alternate docker compose with host path and minimal env.
2025-07-07 16:16:08 +05:30
200 changed files with 31233 additions and 3318 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(docker build:*)"
],
"deny": []
}
}

View File

@@ -1,19 +1,40 @@
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=arunavo4/gitea-mirror
DOCKER_TAG=latest
# Gitea Mirror Configuration
# Copy this to .env and update with your values
# ===========================================
# CORE CONFIGURATION
# ===========================================
# Application Configuration
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
# Database Configuration
# For self-hosted, SQLite is used by default
DATABASE_URL=sqlite://data/gitea-mirror.db
# Security
JWT_SECRET=change-this-to-a-secure-random-string-in-production
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# 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.
# ===========================================
# DOCKER CONFIGURATION (Optional)
# ===========================================
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=arunavo4/gitea-mirror
DOCKER_TAG=latest
# ===========================================
# MIRROR CONFIGURATION (Optional)
# Can also be configured via web UI
# ===========================================
# GitHub Configuration
# GITHUB_USERNAME=your-github-username
# GITHUB_TOKEN=your-github-personal-access-token
# SKIP_FORKS=false
@@ -25,6 +46,8 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
# PRESERVE_ORG_STRUCTURE=false
# ONLY_MIRROR_ORGS=false
# SKIP_STARRED_ISSUES=false
# Gitea Configuration
# GITEA_URL=http://gitea:3000
# GITEA_TOKEN=your-local-gitea-token
# GITEA_USERNAME=your-local-gitea-username
@@ -32,8 +55,27 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
# GITEA_ORG_VISIBILITY=public
# DELAY=3600
# Optional Database Cleanup Configuration (configured via web UI)
# These environment variables are optional and only used as defaults
# Users can configure cleanup settings through the web interface
# ===========================================
# OPTIONAL FEATURES
# ===========================================
# Database Cleanup Configuration
# CLEANUP_ENABLED=false
# CLEANUP_RETENTION_DAYS=7
# TLS/SSL Configuration
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
# ===========================================
# AUTHENTICATION CONFIGURATION
# ===========================================
# Header Authentication (for Reverse Proxy SSO)
# Enable automatic authentication via reverse proxy headers
# HEADER_AUTH_ENABLED=false
# HEADER_AUTH_USER_HEADER=X-Authentik-Username
# HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
# HEADER_AUTH_NAME_HEADER=X-Authentik-Name
# HEADER_AUTH_AUTO_PROVISION=false
# HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [arunavo4] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 854 KiB

BIN
.github/assets/activity_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

After

Width:  |  Height:  |  Size: 950 KiB

BIN
.github/assets/configuration_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

BIN
.github/assets/dashboard_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

BIN
.github/assets/organisation.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

BIN
.github/assets/organisation_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

BIN
.github/assets/repositories_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

7
.gitignore vendored
View File

@@ -25,3 +25,10 @@ data/gitea-mirror.db
# jetbrains setting folder
.idea/
# Custom CA certificates (exclude actual certs but keep README)
certs/*.crt
certs/*.pem
certs/*.cer
!certs/README.md

View File

@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [3.1.0] - 2025-07-21
### Added
- Support for GITHUB_EXCLUDED_ORGS environment variable to filter out specific organizations during discovery
- New textarea UI component for improved form inputs in configuration
### Fixed
- Fixed test failures related to mirror strategy configuration location
- Corrected organization repository routing logic for different mirror strategies
- Fixed starred repositories organization routing bug
- Resolved SSO and OIDC authentication issues
### Improved
- Enhanced organization configuration for better repository routing control
- Better handling of mirror strategies in test suite
- Improved error handling in authentication flows
## [3.0.0] - 2025-07-17
### 🔴 Breaking Changes
- **Authentication System Overhaul**: Migrated from JWT to Better Auth session-based authentication
- **Login Method Changed**: Users now log in with email instead of username
- **Environment Variables**: `JWT_SECRET` renamed to `BETTER_AUTH_SECRET`, new `BETTER_AUTH_URL` required
- **API Endpoints**: Authentication endpoints moved from `/api/auth/login` to `/api/auth/[...all]`
### Added
- **Token Encryption**: All GitHub and Gitea tokens now encrypted with AES-256-GCM
- **SSO/OIDC Support**: Enterprise authentication with OAuth providers (Google, Azure AD, Okta, Authentik, etc.)
- **Header Authentication**: Support for reverse proxy authentication headers (Authentik, Authelia, Traefik Forward Auth)
- **OAuth Provider**: Gitea Mirror can act as an OIDC provider for other applications
- **Automated Migration**: Docker containers auto-migrate from v2 to v3
- **Session Management**: Improved security with session-based authentication
- **Database Migration System**: Drizzle Kit for better schema management
- **Zod v4 Compatibility**: Updated to Zod v4 for schema validation
### Improved
- **Security**: Enhanced error handling and security practices throughout
- **Documentation**: Comprehensive migration guide for v2 to v3 upgrade
- **User Management**: Better Auth provides improved user lifecycle management
- **Database Schema**: Optimized with proper indexes and relationships
- **Password Hashing**: Using bcrypt via Better Auth for secure password storage
### Fixed
- Mirroring issues for starred repositories
- Various security vulnerabilities in authentication system
- Improved error handling across all API endpoints
### Migration Required
- All users must re-authenticate after upgrade
- Existing tokens will be automatically encrypted
- Database schema updates applied automatically
- See [Migration Guide](MIGRATION_GUIDE.md) for detailed instructions
## [2.22.0] - 2025-07-07
### Added
- Comprehensive mobile and responsive design support across the entire application
- New drawer UI component for enhanced mobile navigation
- Mobile-specific layouts for major components (ActivityLog, Header, Organization, Repository)
- Mobile screenshots in documentation showcasing responsive design
### Improved
- Enhanced mobile user experience with optimized layouts for smaller screens
- Updated organization list cards with better mobile responsiveness
- Better touch interaction support throughout the application
### Fixed
- Type definition issues resolved
- Removed unnecessary console.log statements
### Documentation
- Updated README with mobile usage instructions and screenshots
- Added mobile-specific documentation sections
## [2.20.1] - 2025-07-07
### Fixed

102
CLAUDE.md
View File

@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
DONT HALLUCIATE THINGS. IF YOU DONT KNOW LOOK AT THE CODE OR ASK FOR DOCS
## Project Overview
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
@@ -40,7 +42,7 @@ bun run start # Start production server
- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI
- **Backend**: Bun runtime + SQLite + Drizzle ORM
- **APIs**: GitHub (Octokit) and Gitea APIs
- **Auth**: JWT tokens with bcryptjs password hashing
- **Auth**: Better Auth with email/password, SSO, and OIDC provider support
### Project Structure
- `/src/pages/api/` - API endpoints (Astro API routes)
@@ -68,10 +70,15 @@ export async function POST({ request }: APIContext) {
3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates
4. **Authentication Flow**:
4. **Authentication System**:
- Built on Better Auth library
- Three authentication methods:
- Email & Password (traditional auth)
- SSO (authenticate via external OIDC providers)
- OIDC Provider (act as OIDC provider for other apps)
- Session-based authentication with secure cookies
- First user signup creates admin account
- JWT tokens stored in cookies
- Protected routes check auth via `getUserFromCookie()`
- Protected routes use Better Auth session validation
5. **Mirror Process**:
- Discovers repos from GitHub (user/org)
@@ -79,11 +86,18 @@ export async function POST({ request }: APIContext) {
- Tracks status in database
- Supports scheduled automatic mirroring
6. **Mirror Strategies**: Three ways to organize repositories in Gitea:
6. **Mirror Strategies**: Four ways to organize repositories in Gitea:
- **preserve**: Maintains GitHub structure (default)
- Organization repos → Same organization name in Gitea
- Personal repos → Under your Gitea username
- **single-org**: All repos go to one organization
- All repos → Single configured organization
- **flat-user**: All repos go under user account
- Starred repos always go to separate organization (starredReposOrg)
- All repos → Under your Gitea username
- **mixed**: Hybrid approach
- Organization repos → Preserve structure
- Personal repos → Single configured organization
- Starred repos always go to separate organization (starredReposOrg, default: "starred")
- Routing logic in `getGiteaRepoOwner()` function
### Database Schema (SQLite)
@@ -102,11 +116,18 @@ export async function POST({ request }: APIContext) {
### Development Tips
- Environment variables in `.env` (copy from `.env.example`)
- JWT_SECRET auto-generated if not provided
- BETTER_AUTH_SECRET required for session signing
- Database auto-initializes on first run
- Use `bun run dev:clean` for fresh database start
- Tailwind CSS v4 configured with Vite plugin
### Authentication Setup
- **Better Auth** handles all authentication
- Configuration in `/src/lib/auth.ts` (server) and `/src/lib/auth-client.ts` (client)
- Auth endpoints available at `/api/auth/*`
- SSO providers configured through the web UI
- OIDC provider functionality for external applications
### Common Tasks
**Adding a new API endpoint:**
@@ -125,6 +146,73 @@ export async function POST({ request }: APIContext) {
2. Run `bun run init-db` to recreate database
3. Update related queries in `/src/lib/db/queries/`
## Configuration Options
### GitHub Configuration (UI Fields)
#### Basic Settings (`githubConfig`)
- **username**: GitHub username
- **token**: GitHub personal access token (requires repo and admin:org scopes)
- **privateRepositories**: Include private repositories
- **mirrorStarred**: Mirror starred repositories
### Gitea Configuration (UI Fields)
- **url**: Gitea instance URL
- **username**: Gitea username
- **token**: Gitea access token
- **organization**: Destination organization (for single-org/mixed strategies)
- **starredReposOrg**: Organization for starred repositories (default: "starred")
- **visibility**: Organization visibility - "public", "private", "limited"
- **mirrorStrategy**: Repository organization strategy (set via UI)
- **preserveOrgStructure**: Automatically set based on mirrorStrategy
### Schedule Configuration (`scheduleConfig`)
- **enabled**: Enable automatic mirroring (default: false)
- **interval**: Cron expression or seconds (default: "0 2 * * *" - 2 AM daily)
- **concurrent**: Allow concurrent mirror operations (default: false)
- **batchSize**: Number of repos to process in parallel (default: 10)
### Database Cleanup Configuration (`cleanupConfig`)
- **enabled**: Enable automatic cleanup (default: false)
- **retentionDays**: Days to keep events (stored as seconds internally)
### Mirror Options (UI Fields)
- **mirrorReleases**: Mirror GitHub releases to Gitea
- **mirrorMetadata**: Enable metadata mirroring (master toggle)
- **metadataComponents** (only available when mirrorMetadata is enabled):
- **issues**: Mirror issues
- **pullRequests**: Mirror pull requests
- **labels**: Mirror labels
- **milestones**: Mirror milestones
- **wiki**: Mirror wiki content
### Advanced Options (UI Fields)
- **skipForks**: Skip forked repositories (default: false)
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
### Authentication Configuration
#### SSO Provider Configuration
- **issuerUrl**: OIDC issuer URL (e.g., https://accounts.google.com)
- **domain**: Email domain for this provider
- **providerId**: Unique identifier for the provider
- **clientId**: OAuth client ID from provider
- **clientSecret**: OAuth client secret from provider
- **authorizationEndpoint**: OAuth authorization URL (auto-discovered if supported)
- **tokenEndpoint**: OAuth token exchange URL (auto-discovered if supported)
- **jwksEndpoint**: JSON Web Key Set URL (optional, auto-discovered)
- **userInfoEndpoint**: User information endpoint (optional, auto-discovered)
#### OIDC Provider Settings (for external apps)
- **allowedRedirectUris**: Comma-separated list of allowed redirect URIs
- **clientId**: Generated client ID for the application
- **clientSecret**: Generated client secret for the application
- **scopes**: Available scopes (openid, profile, email)
#### Environment Variables
- **BETTER_AUTH_SECRET**: Secret key for signing sessions (required)
- **BETTER_AUTH_URL**: Base URL for authentication (default: http://localhost:4321)
## Security Guidelines
- **Confidentiality Guidelines**:

182
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,182 @@
# Contributing to Gitea Mirror
Thank you for your interest in contributing to Gitea Mirror! This document provides guidelines and instructions for contributing to the open-source version of the project.
## 🎯 Project Overview
Gitea Mirror is an open-source, self-hosted solution for mirroring GitHub repositories to Gitea instances. This guide provides everything you need to know about contributing to the project.
## 🚀 Getting Started
1. Fork the repository
2. Clone your fork:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
```
3. Install dependencies:
```bash
bun install
```
4. Set up your environment:
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. Start development:
```bash
bun run dev
```
## 🛠 Development Workflow
### Running the Application
```bash
# Development mode
bun run dev
# Build for production
bun run build
# Run tests
bun test
```
### Database Management
```bash
# Initialize database
bun run init-db
# Reset database
bun run cleanup-db && bun run init-db
```
## 📝 Code Guidelines
### General Principles
1. **Keep it Simple**: Gitea Mirror should remain easy to self-host
2. **Focus on Core Features**: Prioritize repository mirroring and synchronization
3. **Database**: Use SQLite for simplicity and portability
4. **Dependencies**: Minimize external dependencies for easier deployment
### Code Style
- Use TypeScript for all new code
- Follow the existing code formatting (Prettier is configured)
- Write meaningful commit messages
- Add tests for new features
### Scope of Contributions
This project focuses on personal/small team use cases. Please keep contributions aligned with:
- Core mirroring functionality
- Self-hosted simplicity
- Minimal external dependencies
- SQLite as the database
- Single-instance deployments
## 🐛 Reporting Issues
1. Check existing issues first
2. Use issue templates when available
3. Provide clear reproduction steps
4. Include relevant logs and screenshots
## 🎯 Pull Request Process
1. Create a feature branch:
```bash
git checkout -b feature/your-feature-name
```
2. Make your changes following the code guidelines
3. Test your changes:
```bash
# Run tests
bun test
# Build and check
bun run build:oss
```
4. Commit your changes:
```bash
git commit -m "feat: add new feature"
```
5. Push to your fork and create a Pull Request
### PR Requirements
- Clear description of changes
- Tests for new functionality
- Documentation updates if needed
- No breaking changes without discussion
- Passes all CI checks
## 🏗 Architecture Overview
```
src/
├── components/ # React components
├── lib/ # Core utilities
│ ├── db/ # Database queries (SQLite only)
│ ├── github/ # GitHub API integration
│ ├── gitea/ # Gitea API integration
│ └── utils/ # Helper functions
├── pages/ # Astro pages
│ └── api/ # API endpoints
└── types/ # TypeScript types
```
## 🧪 Testing
```bash
# Run all tests
bun test
# Run tests in watch mode
bun test:watch
# Run with coverage
bun test:coverage
```
## 📚 Documentation
- Update README.md for user-facing changes
- Add JSDoc comments for new functions
- Update .env.example for new environment variables
## 💡 Feature Requests
We welcome feature requests! When proposing new features, please consider:
- Does it enhance the core mirroring functionality?
- Will it benefit self-hosted users?
- Can it be implemented without complex external dependencies?
- Does it maintain the project's simplicity?
## 🤝 Community
- Be respectful and constructive
- Help others in issues and discussions
- Share your use cases and feedback
## 📄 License
By contributing, you agree that your contributions will be licensed under the same license as the project (MIT).
## Questions?
Feel free to open an issue for any questions about contributing!
---
Thank you for helping make Gitea Mirror better! 🎉

View File

@@ -2,7 +2,7 @@
FROM oven/bun:1.2.18-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
# ----------------------------
FROM base AS deps
@@ -31,17 +31,21 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/drizzle ./drizzle
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
RUN chmod +x ./docker-entrypoint.sh && \
# Create directories and setup permissions
RUN mkdir -p /app/certs && \
chmod +x ./docker-entrypoint.sh && \
mkdir -p /app/data && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 gitea-mirror && \
chown -R gitea-mirror:nodejs /app/data
chown -R gitea-mirror:nodejs /app/data && \
chown -R gitea-mirror:nodejs /app/certs
USER gitea-mirror

203
README.md
View File

@@ -10,19 +10,24 @@
</p>
</p>
> [!IMPORTANT]
> **Upgrading to v3?** v3 requires a fresh start with a new data volume. Please read the [Upgrade Guide](UPGRADE.md) for instructions.
## 🚀 Quick Start
```bash
# Using Docker (recommended)
docker compose up -d
# Fastest way - using the simplified Docker setup
docker compose -f docker-compose.alt.yml up -d
# Access at http://localhost:4321
```
First user signup becomes admin. No configuration needed to get started!
First user signup becomes admin. Configure GitHub and Gitea through the web interface!
<p align="center">
<img src=".github/assets/dashboard.png" alt="Dashboard" width="full"/>
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
<img src=".github/assets/dashboard_mobile.png" alt="Dashboard Mobile" width="200" />
</p>
## ✨ Features
@@ -30,51 +35,107 @@ First user signup becomes admin. No configuration needed to get started!
- 🔁 Mirror public, private, and starred GitHub repos to Gitea
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 🔐 Secure authentication with JWT tokens
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 📊 Real-time dashboard with activity logs
- ⏱️ Scheduled automatic mirroring
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
## 📸 Screenshots
<p align="center">
<img src=".github/assets/repositories.png" width="49%"/>
<img src=".github/assets/organisations.png" width="49%"/>
</p>
<div align="center">
<img src=".github/assets/repositories.png" alt="Repositories" width="600" />
<img src=".github/assets/repositories_mobile.png" alt="Rrepositories Mobile" width="200" />
</div>
<div align="center">
<img src=".github/assets/organisation.png" alt="Organisations" width="600" />
<img src=".github/assets/organisation_mobile.png" alt="Organisations Mobile" width="200" />
</div>
## Installation
### Docker (Recommended)
We provide two Docker Compose options:
#### Option 1: Quick Start (docker-compose.alt.yml)
Perfect for trying out Gitea Mirror or simple deployments:
```bash
# Clone repository
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
# Start with Docker Compose
docker compose up -d
# Start with simplified setup
docker compose -f docker-compose.alt.yml up -d
# Access at http://localhost:4321
```
Or use the pre-built image:
**Features:**
- ✅ Pre-built image - no building required
- ✅ Minimal configuration needed
- ✅ Data stored in `./data` directory
- ✅ Configure everything through web UI
- ✅ Automatic user/group ID mapping
**Best for:**
- First-time users
- Testing and evaluation
- Simple deployments
- When you prefer web-based configuration
#### Option 2: Full Setup (docker-compose.yml)
For production deployments with environment-based configuration:
```bash
docker pull ghcr.io/raylabshq/gitea-mirror:v2.20.1
# Start with full configuration options
docker compose up -d
```
**Features:**
- ✅ Build from source or use pre-built image
- ✅ Complete environment variable configuration
- ✅ Support for custom CA certificates
- ✅ Advanced mirror settings (forks, wiki, issues)
- ✅ Multi-registry support
**Best for:**
- Production deployments
- Automated/scripted setups
- Advanced mirror configurations
- When using self-signed certificates
#### Using Pre-built Image Directly
```bash
docker pull ghcr.io/raylabshq/gitea-mirror:v3.0.0
```
### Configuration Options
Create a `.env` file for custom settings (optional):
#### Quick Start Configuration (docker-compose.alt.yml)
Minimal `.env` file (optional - has sensible defaults):
```bash
# JWT secret for authentication (auto-generated if blank or default below)
JWT_SECRET=your-secret-key-change-this-in-production
# Port configuration
# Custom port (default: 4321)
PORT=4321
# User/Group IDs for file permissions (default: 1000)
PUID=1000
PGID=1000
# JWT secret (auto-generated if not set)
JWT_SECRET=your-secret-key-change-this-in-production
```
All other settings are configured through the web interface after starting.
#### Full Setup Configuration (docker-compose.yml)
Supports extensive environment variables for automated deployment. See the full [docker-compose.yml](docker-compose.yml) for all available options including GitHub tokens, Gitea URLs, mirror settings, and more.
### LXC Container (Proxmox)
```bash
@@ -113,6 +174,12 @@ bun run dev
- Override individual repository destinations in the table view
- Starred repositories automatically go to a dedicated organization
## Troubleshooting
### Reverse Proxy Configuration
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
## Development
```bash
@@ -136,6 +203,93 @@ bun run build
- **APIs**: GitHub (Octokit), Gitea REST API
- **Auth**: JWT tokens with bcryptjs password hashing
## Security
### Token Encryption
- All GitHub and Gitea API tokens are encrypted at rest using AES-256-GCM
- Encryption is automatic and transparent to users
- Set `ENCRYPTION_SECRET` environment variable for production deployments
- Falls back to `BETTER_AUTH_SECRET` or `JWT_SECRET` if not set
### Password Security
- User passwords are hashed using bcrypt (via Better Auth)
- Never stored in plaintext
- Secure session management with JWT tokens
### Upgrading to v3
**Important**: If upgrading from v2.x to v3.0, please read the [Migration Guide](MIGRATION_GUIDE.md) for breaking changes and upgrade instructions.
For quick token encryption migration:
```bash
bun run migrate:encrypt-tokens
```
## Authentication
Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.**
### 1. Email & Password (Default)
The standard authentication method. First user to sign up becomes the admin.
### 2. Single Sign-On (SSO) with OIDC
Enable users to sign in with external identity providers like Google, Azure AD, Okta, Authentik, or any OIDC-compliant service.
**Configuration:**
1. Navigate to Settings → Authentication & SSO
2. Click "Add Provider"
3. Enter your OIDC provider details:
- Issuer URL (e.g., `https://accounts.google.com`)
- Client ID and Secret from your provider
- Use the "Discover" button to auto-fill endpoints
**Redirect URL for your provider:**
```
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
### 3. Header Authentication (Reverse Proxy)
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.
**Environment Variables:**
```bash
# Enable header authentication
HEADER_AUTH_ENABLED=true
# Header names (customize based on your proxy)
HEADER_AUTH_USER_HEADER=X-Authentik-Username
HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
HEADER_AUTH_NAME_HEADER=X-Authentik-Name
# Auto-provision new users
HEADER_AUTH_AUTO_PROVISION=true
# Restrict to specific email domains (optional)
HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
```
**How it works:**
- Users authenticated by your reverse proxy are automatically logged in
- No additional login step required
- New users can be auto-provisioned if enabled
- Falls back to regular authentication if headers are missing
**Example Authentik Configuration:**
```nginx
# In your reverse proxy configuration
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
```
### 4. OAuth Applications (Act as Identity Provider)
Gitea Mirror can also act as an OIDC provider for other applications. Register OAuth applications in Settings → Authentication & SSO → OAuth Applications tab.
**Use cases:**
- Allow other services to authenticate using Gitea Mirror accounts
- Create service-to-service authentication
- Build integrations with your Gitea Mirror instance
## Contributing
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
@@ -144,9 +298,20 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
## Star History
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=Date" />
</picture>
</a>
## Support
- 📖 [Documentation](https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs)
- 🔐 [Custom CA Certificates](docs/CA_CERTIFICATES.md)
- 🐛 [Report Issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
- 💬 [Discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)

74
UPGRADE.md Normal file
View File

@@ -0,0 +1,74 @@
# Upgrade Guide
## Upgrading to v3.0
> **⚠️ IMPORTANT**: v3.0 requires a fresh start. There is no automated migration from v2.x to v3.0.
### Why No Migration?
v3.0 introduces fundamental changes to the application architecture:
- **Authentication**: Switched from JWT to Better Auth
- **Database**: Now uses Drizzle ORM with proper migrations
- **Security**: All tokens are now encrypted
- **Features**: Added SSO support and OIDC provider functionality
Due to these extensive changes, we recommend starting fresh with v3.0 for the best experience.
### Upgrade Steps
1. **Stop your v2.x container**
```bash
docker stop gitea-mirror
docker rm gitea-mirror
```
2. **Backup your v2.x data (optional)**
```bash
# If you want to keep your v2 data for reference
docker run --rm -v gitea-mirror-data:/data -v $(pwd):/backup alpine tar czf /backup/gitea-mirror-v2-backup.tar.gz -C /data .
```
3. **Create a new volume for v3**
```bash
docker volume create gitea-mirror-v3-data
```
4. **Run v3 with the new volume**
```bash
docker run -d \
--name gitea-mirror \
-p 4321:4321 \
-v gitea-mirror-v3-data:/app/data \
-e BETTER_AUTH_SECRET=your-secret-key \
-e ENCRYPTION_SECRET=your-encryption-key \
arunavo4/gitea-mirror:latest
```
5. **Set up your configuration again**
- Navigate to http://localhost:4321
- Create a new admin account
- Re-enter your GitHub and Gitea credentials
- Configure your mirror settings
### What Happens to My Existing Mirrors?
Your existing mirrors in Gitea are **not affected**. The application will:
- Recognize existing repositories when you re-import
- Skip creating duplicates
- Resume normal mirror operations
### Environment Variable Changes
v3.0 uses different environment variables:
| v2.x | v3.0 | Notes |
|------|------|-------|
| `JWT_SECRET` | `BETTER_AUTH_SECRET` | Required for session management |
| - | `ENCRYPTION_SECRET` | New - required for token encryption |
### Need Help?
If you have questions about upgrading:
1. Check the [README](README.md) for v3 setup instructions
2. Review your v2 configuration before upgrading
3. Open an issue if you encounter problems

331
bun.lock
View File

@@ -8,6 +8,7 @@
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "9.3.0",
"@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.2",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
@@ -31,13 +32,14 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "5.11.0",
"astro": "5.11.2",
"bcryptjs": "^3.0.2",
"better-auth": "^1.2.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.44.2",
"drizzle-orm": "^0.44.3",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
@@ -45,21 +47,24 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"zod": "^3.25.75",
"vaul": "^1.1.2",
"zod": "^4.0.5",
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.2.18",
"@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",
@@ -95,6 +100,8 @@
"@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.2", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ=="],
"@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
@@ -133,6 +140,12 @@
"@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/sso": ["@better-auth/sso@1.3.2", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "better-auth": "^1.3.2", "fast-xml-parser": "^5.2.5", "jose": "^5.9.6", "oauth2-mock-server": "^7.2.0", "samlify": "^2.10.0", "zod": "^3.24.1" } }, "sha512-Rl7SiPIjJR8qg1XshEV7sPwzU6jk27A3mfXUWSt8PVwO4IgN1iW10DfOEdvmGX47CNSwgVuTBczKpJkQmZzKbw=="],
"@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=="],
@@ -145,6 +158,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=="],
@@ -161,6 +176,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=="],
@@ -219,6 +238,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=="],
@@ -269,8 +290,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=="],
@@ -303,6 +330,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=="],
@@ -451,6 +488,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=="],
@@ -505,6 +546,8 @@
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@@ -575,6 +618,12 @@
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.10", "", {}, "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -597,13 +646,19 @@
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"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=="],
"astro": ["astro@5.11.0", "", { "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.2", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-MEICntERthUxJPSSDsDiZuwiCMrsaYy3fnDhp4c6ScUfldCB8RBnB/myYdpTFXpwYBy6SgVsHQ1H4MuuA7ro/Q=="],
"astro": ["astro@5.11.2", "", { "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.2", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-jKJCqp0PMZ1ZpP2xySghsJ1xK7ZNh/ISTRNBf/7khY3iEGq/zup49ZMhNZXK5Cd/dFWP/pdBNHD91SByA42IvQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -613,12 +668,20 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
"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=="],
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"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=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -629,9 +692,19 @@
"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=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="],
@@ -685,12 +758,20 @@
"common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -723,6 +804,8 @@
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
@@ -741,10 +824,14 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"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=="],
"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.3", "", { "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-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -761,14 +848,22 @@
"entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
"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=="],
@@ -795,6 +890,8 @@
"expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="],
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
@@ -805,22 +902,30 @@
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
"fontace": ["fontace@0.3.0", "", { "dependencies": { "@types/fontkit": "^2.0.8", "fontkit": "^2.0.4" } }, "sha512-czoqATrcnxgWb/nAkfyIrRp6Q8biYj7nGnL6zfhTcX+JKKpWHFBnb8uNMw/kZr7u++3Y3wYSYoZgHkCcsuBpBg=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -829,8 +934,12 @@
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
@@ -839,12 +948,18 @@
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@1.15.3", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
@@ -893,6 +1008,8 @@
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
@@ -919,12 +1036,16 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"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=="],
@@ -947,6 +1068,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=="],
@@ -1003,6 +1126,8 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
@@ -1039,8 +1164,14 @@
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -1113,6 +1244,8 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
@@ -1133,6 +1266,10 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"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=="],
@@ -1143,14 +1280,24 @@
"node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="],
"node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="],
"node-mock-http": ["node-mock-http@1.0.0", "", {}, "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nwsapi": ["nwsapi@2.2.20", "", {}, "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA=="],
"oauth2-mock-server": ["oauth2-mock-server@7.2.1", "", { "dependencies": { "basic-auth": "^2.0.1", "cors": "^2.8.5", "express": "^4.21.2", "is-plain-object": "^5.0.0", "jose": "^5.10.0" }, "bin": { "oauth2-mock-server": "dist\\oauth2-mock-server.js" } }, "sha512-ZXL+VuJU2pvzehseq+7b47ZSN7p2Z7J5GoI793X0oECgdLYdol7tnBbTY/aUxuMkk+xpnE186ZzhnigwCAEBOQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "^2.0.3", "node-fetch-native": "^1.6.4", "ufo": "^1.5.4" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
@@ -1169,7 +1316,7 @@
"package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1177,8 +1324,12 @@
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="],
@@ -1199,14 +1350,24 @@
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"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=="],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
@@ -1285,6 +1446,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=="],
@@ -1293,6 +1456,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"samlify": ["samlify@2.10.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-4zHbKKTvPnnqfGu4tks26K4fJjsY99ylsP7TPMobW5rggwcsxNlyhLE9ucxW3JFCsUcoKXb77QjQjwQo1TtRgw=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
@@ -1301,14 +1466,26 @@
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
"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=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
@@ -1317,12 +1494,14 @@
"smol-toml": ["smol-toml@1.3.4", "", {}, "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA=="],
"sonner": ["sonner@2.0.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ=="],
"sonner": ["sonner@2.0.6", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="],
"source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
"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=="],
@@ -1341,6 +1520,8 @@
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
"style-to-js": ["style-to-js@1.1.16", "", { "dependencies": { "style-to-object": "1.0.8" } }, "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw=="],
"style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
@@ -1397,6 +1578,8 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
@@ -1441,6 +1624,8 @@
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unstorage": ["unstorage@1.16.0", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.2", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.6", "ofetch": "^1.4.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
@@ -1451,8 +1636,14 @@
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
@@ -1523,10 +1714,18 @@
"ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="],
"xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -1547,7 +1746,7 @@
"yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="],
"zod": ["zod@3.25.75", "", {}, "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="],
"zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
@@ -1573,6 +1772,12 @@
"@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=="],
"@better-auth/sso/better-auth": ["better-auth@1.3.2", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-510kOtFBTdp4z51hWtTEqk9yqSinXzyg7PkDFnXYMq1K0KvdXRY1A9t9J998i0CSf/tJA0wNoN3S8exkOgBvTw=="],
"@better-auth/sso/zod": ["zod@3.25.75", "", {}, "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="],
"@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=="],
@@ -1597,9 +1802,23 @@
"@types/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=="],
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"astro/zod": ["zod@3.25.64", "", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="],
"astro/zod": ["zod@3.25.75", "", {}, "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="],
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"better-auth/jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
"better-auth/zod": ["zod@3.25.75", "", {}, "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
"boxen/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
@@ -1609,6 +1828,16 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"express/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
"magicast/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
@@ -1623,8 +1852,20 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"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=="],
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -1635,6 +1876,8 @@
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
"yaml-language-server/vscode-languageserver": ["vscode-languageserver@7.0.0", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="],
@@ -1651,14 +1894,78 @@
"@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=="],
"@better-auth/sso/better-auth/zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
"@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=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"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=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"express/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"serve-static/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"widest-line/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
@@ -1679,6 +1986,8 @@
"boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"yaml-language-server/vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@6.0.0", "", {}, "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg=="],

236
certs/README.md Normal file
View File

@@ -0,0 +1,236 @@
# CA Certificates Configuration
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 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.
## Common SSL/TLS Errors
If you encounter any of these errors, you need to configure CA certificates:
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
- `SELF_SIGNED_CERT_IN_CHAIN`
- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
- `CERT_UNTRUSTED`
- `unable to verify the first certificate`
## Configuration by Deployment Method
### Docker
#### Method 1: Volume Mount (Recommended)
1. Create a certificates directory:
```bash
mkdir -p ./certs
```
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: raylabs/gitea-mirror:latest
volumes:
- ./data:/app/data
- ./certs:/usr/local/share/ca-certificates:ro
environment:
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
```
4. Restart the container:
```bash
docker-compose down && docker-compose up -d
```
#### 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 <container-id>
```
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
```

24
docker-compose.alt.yml Normal file
View File

@@ -0,0 +1,24 @@
# Gitea Mirror alternate deployment configuration
# Standard deployment with host path and minimal environments
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "${PORT:-4321}:4321"
user: ${PUID:-1000}:${PGID:-1000}
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- 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
timeout: 10s
retries: 5
start_period: 15s

View File

@@ -54,6 +54,11 @@ services:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
# Mount custom CA certificates - choose one option:
# Option 1: Mount individual CA certificates from certs directory
# - ./certs:/app/certs:ro
# Option 2: Mount system CA bundle (if your CA is already in system store)
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
depends_on:
- gitea
environment:
@@ -61,10 +66,11 @@ 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}
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
@@ -80,6 +86,8 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
interval: 30s

View File

@@ -18,15 +18,24 @@ services:
- "4321:4321"
volumes:
- gitea-mirror-data:/app/data
# Mount custom CA certificates - choose one option:
# Option 1: Mount individual CA certificates from certs directory
# - ./certs:/app/certs:ro
# Option 2: Mount system CA bundle (if your CA is already in system store)
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
environment:
- NODE_ENV=production
- 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}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
@@ -42,6 +51,15 @@ services:
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
- DELAY=${DELAY:-3600}
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
# Header Authentication (for Reverse Proxy SSO)
- HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false}
- HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username}
- HEADER_AUTH_EMAIL_HEADER=${HEADER_AUTH_EMAIL_HEADER:-X-Authentik-Email}
- HEADER_AUTH_NAME_HEADER=${HEADER_AUTH_NAME_HEADER:-X-Authentik-Name}
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s

View File

@@ -5,15 +5,73 @@ set -e
# Ensure data directory exists
mkdir -p /app/data
# 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
# Handle custom CA certificates
if [ -d "/app/certs" ] && [ "$(ls -A /app/certs/*.crt 2>/dev/null)" ]; then
echo "Custom CA certificates found, configuring Node.js to use them..."
# Combine all CA certificates into a bundle for Node.js
CA_BUNDLE="/app/certs/ca-bundle.crt"
> "$CA_BUNDLE"
for cert in /app/certs/*.crt; do
if [ -f "$cert" ]; then
echo "Adding certificate: $(basename "$cert")"
cat "$cert" >> "$CA_BUNDLE"
echo "" >> "$CA_BUNDLE" # Add newline between certificates
fi
done
# Set Node.js to use the custom CA bundle
export NODE_EXTRA_CA_CERTS="$CA_BUNDLE"
echo "NODE_EXTRA_CA_CERTS set to: $NODE_EXTRA_CA_CERTS"
# For Bun compatibility, also set the CA bundle in system location if writable
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ -w "/etc/ssl/certs/" ]; then
echo "Appending custom certificates to system CA bundle..."
cat "$CA_BUNDLE" >> /etc/ssl/certs/ca-certificates.crt
fi
else
echo "No custom CA certificates found in /app/certs"
fi
# Check if system CA bundle is mounted and use it
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
# Check if it's a mounted file (not the default symlink)
if [ "$(stat -c '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -c '%d' / 2>/dev/null)" ] || \
[ "$(stat -f '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -f '%d' / 2>/dev/null)" ]; then
echo "System CA bundle mounted, configuring Node.js to use it..."
export NODE_EXTRA_CA_CERTS="/etc/ssl/certs/ca-certificates.crt"
echo "NODE_EXTRA_CA_CERTS set to: $NODE_EXTRA_CA_CERTS"
fi
fi
# Optional: If GITEA_SKIP_TLS_VERIFY is set, configure accordingly
if [ "$GITEA_SKIP_TLS_VERIFY" = "true" ]; then
echo "Warning: GITEA_SKIP_TLS_VERIFY is set to true. This is insecure!"
export NODE_TLS_REJECT_UNAUTHORIZED=0
fi
# 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)
@@ -22,12 +80,38 @@ 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
# Generate a secure ENCRYPTION_SECRET if one isn't provided
ENCRYPTION_SECRET_FILE="/app/data/.encryption_secret"
if [ -z "$ENCRYPTION_SECRET" ]; then
# Check if we have a previously generated secret
if [ -f "$ENCRYPTION_SECRET_FILE" ]; then
echo "Using previously generated ENCRYPTION_SECRET"
export ENCRYPTION_SECRET=$(cat "$ENCRYPTION_SECRET_FILE")
else
echo "Generating a secure random ENCRYPTION_SECRET"
# Generate a 48-character secret for encryption
if command -v openssl >/dev/null 2>&1; then
GENERATED_ENCRYPTION_SECRET=$(openssl rand -base64 36)
else
# Fallback to using /dev/urandom if openssl is not available
echo "OpenSSL not found, using fallback method for encryption secret generation"
GENERATED_ENCRYPTION_SECRET=$(head -c 36 /dev/urandom | base64 | tr -d '\n' | head -c 48)
fi
export ENCRYPTION_SECRET="$GENERATED_ENCRYPTION_SECRET"
# Save the secret to a file for persistence across container restarts
echo "$GENERATED_ENCRYPTION_SECRET" > "$ENCRYPTION_SECRET_FILE"
chmod 600 "$ENCRYPTION_SECRET_FILE"
fi
echo "ENCRYPTION_SECRET has been set to a secure random value"
fi
@@ -185,19 +269,7 @@ else
bun scripts/manage-db.ts fix
fi
# Run database migrations
echo "Running database migrations..."
# Update mirror_jobs table with new columns for resilience
if [ -f "dist/scripts/update-mirror-jobs-table.js" ]; then
echo "Updating mirror_jobs table..."
bun dist/scripts/update-mirror-jobs-table.js
elif [ -f "scripts/update-mirror-jobs-table.ts" ]; then
echo "Updating mirror_jobs table using TypeScript script..."
bun scripts/update-mirror-jobs-table.ts
else
echo "Warning: Could not find mirror_jobs table update script."
fi
echo "Database exists, checking integrity..."
fi
# Extract version from package.json and set as environment variable

View File

@@ -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)

206
docs/BUILD_GUIDE.md Normal file
View File

@@ -0,0 +1,206 @@
# Build Guide
This guide covers building the open-source version of Gitea Mirror.
## Prerequisites
- **Bun** >= 1.2.9 (primary runtime)
- **Node.js** >= 20 (for compatibility)
- **Git**
## Quick Start
```bash
# Clone repository
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
# Install dependencies
bun install
# Initialize database
bun run init-db
# Build for production
bun run build
# Start the application
bun run start
```
## Build Commands
| Command | Description |
|---------|-------------|
| `bun run build` | Production build |
| `bun run dev` | Development server |
| `bun run preview` | Preview production build |
| `bun test` | Run tests |
| `bun run cleanup-db` | Remove database files |
## Build Output
The build creates:
- `dist/` - Production-ready server files
- `.astro/` - Build cache (git-ignored)
- `data/` - SQLite database location
## Development Build
For active development with hot reload:
```bash
bun run dev
```
Access the application at http://localhost:4321
## Production Build
```bash
# Build
bun run build
# Test the build
bun run preview
# Run in production
bun run start
```
## Docker Build
```dockerfile
# Build Docker image
docker build -t gitea-mirror:latest .
# Run container
docker run -p 3000:3000 gitea-mirror:latest
```
## Environment Variables
Create a `.env` file:
```env
# Database
DATABASE_PATH=./data/gitea-mirror.db
# Authentication
JWT_SECRET=your-secret-here
# GitHub Configuration
GITHUB_TOKEN=ghp_...
GITHUB_WEBHOOK_SECRET=...
GITHUB_EXCLUDED_ORGS=org1,org2,org3 # Optional: Comma-separated list of organizations to exclude from sync
# Gitea Configuration
GITEA_URL=https://your-gitea.com
GITEA_TOKEN=...
```
## Common Build Issues
### Missing Dependencies
```bash
# Solution
bun install
```
### Database Not Initialized
```bash
# Solution
bun run init-db
```
### Port Already in Use
```bash
# Change port
PORT=3001 bun run dev
```
### Build Cache Issues
```bash
# Clear cache
rm -rf .astro/ dist/
bun run build
```
## Build Optimization
### Development Speed
- Use `bun run dev` for hot reload
- Skip type checking during rapid development
- Keep `.astro/` cache between builds
### Production Optimization
- Minification enabled automatically
- Tree shaking removes unused code
- Image optimization with Sharp
## Validation
After building, verify:
```bash
# Check build output
ls -la dist/
# Test server starts
bun run start
# Check health endpoint
curl http://localhost:3000/api/health
```
## CI/CD Build
Example GitHub Actions workflow:
```yaml
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: bun test
```
## Troubleshooting
### Build Fails
1. Check Bun version: `bun --version`
2. Clear dependencies: `rm -rf node_modules && bun install`
3. Check for syntax errors: `bunx tsc --noEmit`
### Runtime Errors
1. Check environment variables
2. Verify database exists
3. Check file permissions
## Performance
Expected build times:
- Clean build: ~5-10 seconds
- Incremental build: ~2-5 seconds
- Development startup: ~1-2 seconds
## Next Steps
- Configure with [Configuration Guide](./CONFIGURATION.md)
- Deploy with [Deployment Guide](./DEPLOYMENT.md)
- Set up authentication with [SSO Guide](./SSO-OIDC-SETUP.md)

1
docs/CA_CERTIFICATES.md Symbolic link
View File

@@ -0,0 +1 @@
../certs/README.md

View File

@@ -0,0 +1,355 @@
# Development Workflow
This guide covers the development workflow for the open-source Gitea Mirror.
## Getting Started
### Prerequisites
- Bun >= 1.2.9
- Node.js >= 20
- Git
- GitHub account (for API access)
- Gitea instance (for testing)
### Initial Setup
1. **Clone the repository**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
```
2. **Install dependencies**:
```bash
bun install
```
3. **Initialize database**:
```bash
bun run init-db
```
4. **Configure environment**:
```bash
cp .env.example .env
# Edit .env with your settings
```
5. **Start development server**:
```bash
bun run dev
```
## Development Commands
| Command | Description |
|---------|-------------|
| `bun run dev` | Start development server with hot reload |
| `bun run build` | Build for production |
| `bun run preview` | Preview production build |
| `bun test` | Run all tests |
| `bun test:watch` | Run tests in watch mode |
| `bun run db:studio` | Open database GUI |
## Project Structure
```
gitea-mirror/
├── src/
│ ├── components/ # React components
│ ├── pages/ # Astro pages & API routes
│ ├── lib/ # Core logic
│ │ ├── db/ # Database queries
│ │ ├── utils/ # Helper functions
│ │ └── modules/ # Module system
│ ├── hooks/ # React hooks
│ └── types/ # TypeScript types
├── public/ # Static assets
├── scripts/ # Utility scripts
└── tests/ # Test files
```
## Feature Development
### Adding a New Feature
1. **Create feature branch**:
```bash
git checkout -b feature/my-feature
```
2. **Plan your changes**:
- UI components in `/src/components/`
- API endpoints in `/src/pages/api/`
- Database queries in `/src/lib/db/queries/`
- Types in `/src/types/`
3. **Implement the feature**:
**Example: Adding a new API endpoint**
```typescript
// src/pages/api/my-endpoint.ts
import type { APIRoute } from 'astro';
import { getUserFromCookie } from '@/lib/auth-utils';
export const GET: APIRoute = async ({ request }) => {
const user = await getUserFromCookie(request);
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Your logic here
return new Response(JSON.stringify({ data: 'success' }), {
headers: { 'Content-Type': 'application/json' }
});
};
```
4. **Write tests**:
```typescript
// src/lib/my-feature.test.ts
import { describe, it, expect } from 'bun:test';
describe('My Feature', () => {
it('should work correctly', () => {
expect(myFunction()).toBe('expected');
});
});
```
5. **Update documentation**:
- Add JSDoc comments
- Update README if needed
- Document API changes
## Database Development
### Schema Changes
1. **Modify schema**:
```typescript
// src/lib/db/schema.ts
export const myTable = sqliteTable('my_table', {
id: text('id').primaryKey(),
name: text('name').notNull(),
createdAt: integer('created_at').notNull(),
});
```
2. **Generate migration**:
```bash
bun run db:generate
```
3. **Apply migration**:
```bash
bun run db:migrate
```
### Writing Queries
```typescript
// src/lib/db/queries/my-queries.ts
import { db } from '../index';
import { myTable } from '../schema';
export async function getMyData(userId: string) {
return db.select()
.from(myTable)
.where(eq(myTable.userId, userId));
}
```
## Testing
### Unit Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test auth
# Watch mode
bun test:watch
# Coverage
bun test:coverage
```
### Manual Testing Checklist
- [ ] Feature works as expected
- [ ] No console errors
- [ ] Responsive on mobile
- [ ] Handles errors gracefully
- [ ] Loading states work
- [ ] Form validation works
- [ ] API returns correct status codes
## Debugging
### VSCode Configuration
Create `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "launch",
"name": "Debug Bun",
"program": "${workspaceFolder}/src/index.ts",
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development"
}
}
]
}
```
### Debug Logging
```typescript
// Development only logging
if (import.meta.env.DEV) {
console.log('[Debug]', data);
}
```
## Code Style
### TypeScript
- Use strict mode
- Define interfaces for all data structures
- Avoid `any` type
- Use proper error handling
### React Components
- Use functional components
- Implement proper loading states
- Handle errors with error boundaries
- Use TypeScript for props
### API Routes
- Always validate input
- Return proper status codes
- Use consistent error format
- Document with JSDoc
## Git Workflow
### Commit Messages
Follow conventional commits:
```
feat: add repository filtering
fix: resolve sync timeout issue
docs: update API documentation
style: format code with prettier
refactor: simplify auth logic
test: add user creation tests
chore: update dependencies
```
### Pull Request Process
1. Create feature branch
2. Make changes
3. Write/update tests
4. Update documentation
5. Create PR with description
6. Address review feedback
7. Squash and merge
## Performance
### Development Tips
- Use React DevTools
- Monitor bundle size
- Profile database queries
- Check memory usage
### Optimization
- Lazy load components
- Optimize images
- Use database indexes
- Cache API responses
## Common Issues
### Port Already in Use
```bash
# Use different port
PORT=3001 bun run dev
```
### Database Locked
```bash
# Reset database
bun run cleanup-db
bun run init-db
```
### Type Errors
```bash
# Check types
bunx tsc --noEmit
```
## Release Process
1. **Update version**:
```bash
npm version patch # or minor/major
```
2. **Update CHANGELOG.md**
3. **Build and test**:
```bash
bun run build
bun test
```
4. **Create release**:
```bash
git tag v2.23.0
git push origin v2.23.0
```
5. **Create GitHub release**
## Contributing
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to your fork
5. Create a Pull Request
## Resources
- [Astro Documentation](https://docs.astro.build)
- [Bun Documentation](https://bun.sh/docs)
- [Drizzle ORM](https://orm.drizzle.team)
- [React Documentation](https://react.dev)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
## Getting Help
- Check existing [issues](https://github.com/yourusername/gitea-mirror/issues)
- Join [discussions](https://github.com/yourusername/gitea-mirror/discussions)
- Read the [FAQ](./FAQ.md)

77
docs/EXTENDING.md Normal file
View File

@@ -0,0 +1,77 @@
# Extending Gitea Mirror
Gitea Mirror is designed with extensibility in mind through a module system.
## Module System
The application provides a module interface that allows extending functionality:
```typescript
export interface Module {
name: string;
version: string;
init(app: AppContext): Promise<void>;
cleanup?(): Promise<void>;
}
```
## Creating Custom Modules
You can create custom modules to add features:
```typescript
// my-module.ts
export class MyModule implements Module {
name = 'my-module';
version = '1.0.0';
async init(app: AppContext) {
// Add your functionality
app.addRoute('/api/my-endpoint', this.handler);
}
async handler(context) {
return new Response('Hello from my module!');
}
}
```
## Module Context
Modules receive an `AppContext` with:
- Database access
- Event system
- Route registration
- Configuration
## Private Extensions
If you're developing private extensions:
1. Create a separate package/repository
2. Implement the module interface
3. Use Bun's linking feature for development:
```bash
# In your extension
bun link
# In gitea-mirror
bun link your-extension
```
## Best Practices
- Keep modules focused on a single feature
- Use TypeScript for type safety
- Handle errors gracefully
- Clean up resources in `cleanup()`
- Document your module's API
## Community Modules
Share your modules with the community:
- Create a GitHub repository
- Tag it with `gitea-mirror-module`
- Submit a PR to list it in our docs
For more details on the module system, see the source code in `/src/lib/modules/`.

118
docs/README.md Normal file
View File

@@ -0,0 +1,118 @@
# Gitea Mirror Documentation
Welcome to the Gitea Mirror documentation. This guide covers everything you need to know about developing, building, and deploying the open-source version of Gitea Mirror.
## Documentation Overview
### Getting Started
- **[Development Workflow](./DEVELOPMENT_WORKFLOW.md)** - Set up your development environment and start contributing
- **[Build Guide](./BUILD_GUIDE.md)** - Build Gitea Mirror from source
- **[Configuration Guide](./CONFIGURATION.md)** - Configure all available options
### Deployment
- **[Deployment Guide](./DEPLOYMENT.md)** - Deploy to production environments
- **[Docker Guide](./DOCKER.md)** - Container-based deployment
- **[Reverse Proxy Setup](./REVERSE_PROXY.md)** - Configure with nginx/Caddy
### Features
- **[SSO/OIDC Setup](./SSO-OIDC-SETUP.md)** - Configure authentication providers
- **[Sponsor Integration](./SPONSOR_INTEGRATION.md)** - GitHub Sponsors integration
- **[Webhook Configuration](./WEBHOOKS.md)** - Set up GitHub webhooks
### Architecture
- **[Architecture Overview](./ARCHITECTURE.md)** - System design and components
- **[API Documentation](./API.md)** - REST API endpoints
- **[Database Schema](./DATABASE.md)** - SQLite structure
### Maintenance
- **[Migration Guide](../MIGRATION_GUIDE.md)** - Upgrade from previous versions
- **[Better Auth Migration](./BETTER_AUTH_MIGRATION.md)** - Migrate authentication system
- **[Troubleshooting](./TROUBLESHOOTING.md)** - Common issues and solutions
- **[Backup & Restore](./BACKUP.md)** - Data management
## Quick Start
1. **Clone and install**:
```bash
git clone https://github.com/yourusername/gitea-mirror.git
cd gitea-mirror
bun install
```
2. **Configure**:
```bash
cp .env.example .env
# Edit .env with your GitHub and Gitea tokens
```
3. **Initialize and run**:
```bash
bun run init-db
bun run dev
```
4. **Access**: Open http://localhost:4321
## Key Features
- 🔄 **Automatic Mirroring** - Keep repositories synchronized
- 🗂️ **Organization Support** - Mirror entire organizations
-**Starred Repos** - Mirror your starred repositories
- 🔐 **Self-Hosted** - Full control over your data
- 🚀 **Fast** - Built with Bun for optimal performance
- 🔒 **Secure** - JWT authentication, encrypted tokens
## Technology Stack
- **Runtime**: Bun
- **Framework**: Astro with React
- **Database**: SQLite with Drizzle ORM
- **Styling**: Tailwind CSS v4
- **Authentication**: Better Auth
## System Requirements
- Bun >= 1.2.9
- Node.js >= 20 (optional, for compatibility)
- SQLite 3
- 512MB RAM minimum
- 1GB disk space
## Contributing
We welcome contributions! Please see our [Contributing Guide](../CONTRIBUTING.md) for details.
### Development Setup
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
### Code of Conduct
Please read our [Code of Conduct](../CODE_OF_CONDUCT.md) before contributing.
## Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/gitea-mirror/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/gitea-mirror/discussions)
- **Wiki**: [GitHub Wiki](https://github.com/yourusername/gitea-mirror/wiki)
## Security
For security issues, please see [SECURITY.md](../SECURITY.md).
## License
Gitea Mirror is open source software licensed under the [MIT License](../LICENSE).
---
For detailed information on any topic, please refer to the specific documentation guides listed above.

View File

@@ -0,0 +1,91 @@
# GitHub Sponsors Integration
This guide shows how GitHub Sponsors is integrated into the open-source version of Gitea Mirror.
## Components
### GitHubSponsors Card
A card component that displays in the sidebar or dashboard:
```tsx
import { GitHubSponsors } from '@/components/sponsors/GitHubSponsors';
// In your layout or dashboard
<GitHubSponsors />
```
### SponsorButton
A smaller button for headers or navigation:
```tsx
import { SponsorButton } from '@/components/sponsors/GitHubSponsors';
// In your header
<SponsorButton />
```
## Integration Points
### 1. Dashboard Sidebar
Add the sponsor card to the dashboard sidebar for visibility:
```tsx
// src/components/layout/DashboardLayout.tsx
<aside>
{/* Other sidebar content */}
<GitHubSponsors />
</aside>
```
### 2. Header Navigation
Add the sponsor button to the main navigation:
```tsx
// src/components/layout/Header.tsx
<nav>
{/* Other nav items */}
<SponsorButton />
</nav>
```
### 3. Settings Page
Add a support section in settings:
```tsx
// src/components/settings/SupportSection.tsx
<Card>
<CardHeader>
<CardTitle>Support Development</CardTitle>
</CardHeader>
<CardContent>
<GitHubSponsors />
</CardContent>
</Card>
```
## Behavior
- **Only appears in self-hosted mode**: The components automatically hide in hosted mode
- **Non-intrusive**: Designed to be helpful without being annoying
- **Multiple options**: GitHub Sponsors, Buy Me a Coffee, and starring the repo
## Customization
You can customize the sponsor components by:
1. Updating the GitHub Sponsors URL
2. Adding/removing donation platforms
3. Changing the styling to match your theme
4. Adjusting the placement based on user feedback
## Best Practices
1. **Don't be pushy**: Show sponsor options tastefully
2. **Provide value first**: Ensure the tool is useful before asking for support
3. **Be transparent**: Explain how sponsorships help the project
4. **Thank sponsors**: Acknowledge supporters in README or releases

205
docs/SSO-OIDC-SETUP.md Normal file
View File

@@ -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

16
drizzle.config.ts Normal file
View File

@@ -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",
},
});

180
drizzle/0000_init.sql Normal file
View File

@@ -0,0 +1,180 @@
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,
`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 `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,
`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,
`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`);

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1752171873627,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1752173351102,
"tag": "0001_polite_exodus",
"breakpoints": true
}
]
}

9
env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference path="./.astro/types.d.ts" />
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user: import("better-auth").User | null;
session: import("better-auth").Session | null;
}
}

9087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.20.1",
"version": "3.1.0",
"engines": {
"bun": ">=1.2.9"
},
"scripts": {
"setup": "bun install && bun run manage-db init",
"dev": "bunx --bun astro dev",
"dev": "bunx --bun astro dev --port 9876",
"dev:clean": "bun run cleanup-db && bun run manage-db init && bunx --bun astro dev",
"build": "bunx --bun astro build",
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
@@ -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",
@@ -35,6 +41,7 @@
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "9.3.0",
"@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.2",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
@@ -58,13 +65,14 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "5.11.0",
"astro": "5.11.2",
"bcryptjs": "^3.0.2",
"better-auth": "^1.2.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.44.2",
"drizzle-orm": "^0.44.3",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
@@ -72,21 +80,24 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"zod": "^3.25.75"
"vaul": "^1.1.2",
"zod": "^4.0.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.2.18",
"@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"

View File

@@ -10,7 +10,7 @@
*/
import { db, mirrorJobs } from "../src/lib/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -21,18 +21,19 @@ async function fixInterruptedJobs() {
console.log("Checking for interrupted jobs...");
// Build the query
let query = db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.inProgress, true));
const whereConditions = userId
? and(eq(mirrorJobs.inProgress, true), eq(mirrorJobs.userId, userId))
: eq(mirrorJobs.inProgress, true);
if (userId) {
console.log(`Filtering for user: ${userId}`);
query = query.where(eq(mirrorJobs.userId, userId));
}
// Find all in-progress jobs
const inProgressJobs = await query;
const inProgressJobs = await db
.select()
.from(mirrorJobs)
.where(whereConditions);
if (inProgressJobs.length === 0) {
console.log("No interrupted jobs found.");
@@ -45,7 +46,7 @@ async function fixInterruptedJobs() {
});
// Mark all in-progress jobs as failed
let updateQuery = db
await db
.update(mirrorJobs)
.set({
inProgress: false,
@@ -53,13 +54,7 @@ async function fixInterruptedJobs() {
status: "failed",
message: "Job interrupted and marked as failed by cleanup script"
})
.where(eq(mirrorJobs.inProgress, true));
if (userId) {
updateQuery = updateQuery.where(eq(mirrorJobs.userId, userId));
}
await updateQuery;
.where(whereConditions);
console.log(`✅ Successfully marked ${inProgressJobs.length} interrupted jobs as failed.`);
console.log("These jobs can now be deleted through the normal cleanup process.");

View File

@@ -0,0 +1,110 @@
#!/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
// Note: $internal API is not available in current better-auth version
// 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();

View File

@@ -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

View File

@@ -79,11 +79,11 @@ async function investigateRepository() {
if (config.length > 0) {
const userConfig = config[0];
console.log(` User ID: ${userConfig.userId}`);
console.log(` GitHub Username: ${userConfig.githubConfig?.username || "Not set"}`);
console.log(` GitHub Owner: ${userConfig.githubConfig?.owner || "Not set"}`);
console.log(` Gitea URL: ${userConfig.giteaConfig?.url || "Not set"}`);
console.log(` Gitea Username: ${userConfig.giteaConfig?.username || "Not set"}`);
console.log(` Preserve Org Structure: ${userConfig.githubConfig?.preserveOrgStructure || false}`);
console.log(` Mirror Issues: ${userConfig.githubConfig?.mirrorIssues || false}`);
console.log(` Gitea Default Owner: ${userConfig.giteaConfig?.defaultOwner || "Not set"}`);
console.log(` Mirror Strategy: ${userConfig.githubConfig?.mirrorStrategy || "preserve"}`);
console.log(` Include Starred: ${userConfig.githubConfig?.includeStarred || false}`);
}
// Check for any active jobs
@@ -123,7 +123,7 @@ async function investigateRepository() {
try {
const giteaUrl = userConfig.giteaConfig?.url;
const giteaToken = userConfig.giteaConfig?.token;
const giteaUsername = userConfig.giteaConfig?.username;
const giteaUsername = userConfig.giteaConfig?.defaultOwner;
if (giteaUrl && giteaToken && giteaUsername) {
const checkUrl = `${giteaUrl}/api/v1/repos/${giteaUsername}/${repo.name}`;

View File

@@ -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);
}

View File

@@ -81,21 +81,23 @@ async function repairMirroredRepositories() {
try {
// Find repositories that might need repair
let query = db
.select()
.from(repositories)
.where(
or(
const whereConditions = specificRepo
? and(
or(
eq(repositories.status, "imported"),
eq(repositories.status, "failed")
),
eq(repositories.name, specificRepo)
)
: or(
eq(repositories.status, "imported"),
eq(repositories.status, "failed")
)
);
);
if (specificRepo) {
query = query.where(eq(repositories.name, specificRepo));
}
const repos = await query;
const repos = await db
.select()
.from(repositories)
.where(whereConditions);
if (repos.length === 0) {
if (!isStartupMode) {
@@ -137,7 +139,7 @@ async function repairMirroredRepositories() {
}
const userConfig = config[0];
const giteaUsername = userConfig.giteaConfig?.username;
const giteaUsername = userConfig.giteaConfig?.defaultOwner;
if (!giteaUsername) {
if (!isStartupMode) {

31
scripts/run-migration.ts Normal file
View File

@@ -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();
}

View File

@@ -47,7 +47,7 @@ async function createTestJob(): Promise<string> {
jobType: "mirror",
totalItems: 10,
itemIds: ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
completedItemIds: ['item-1', 'item-2'], // Simulate partial completion
completedItems: 2, // Simulate partial completion
inProgress: true,
});

View File

@@ -1,4 +1,3 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";

View File

@@ -3,11 +3,17 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { MirrorJob } from '@/lib/db/schema';
import Fuse from 'fuse.js';
import { Button } from '../ui/button';
import { RefreshCw } from 'lucide-react';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -73,7 +79,7 @@ export default function ActivityList({
count: filteredActivities.length,
getScrollElement: () => parentRef.current,
estimateSize: (idx) =>
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height + 8,
});
@@ -155,8 +161,8 @@ export default function ActivityList({
}}
className='border-b px-4 pt-4'
>
<div className='flex items-start gap-4'>
<div className='relative mt-2'>
<div className='flex items-start gap-3 sm:gap-4'>
<div className='relative mt-2 flex-shrink-0'>
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status,
@@ -164,25 +170,112 @@ export default function ActivityList({
/>
</div>
<div className='flex-1'>
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
<p className='font-medium'>{activity.message}</p>
<p className='text-sm text-muted-foreground'>
<div className='flex-1 min-w-0'>
<div className='mb-1 flex items-start justify-between gap-2'>
<div className='flex-1 min-w-0'>
{/* Mobile: Show simplified status-based message */}
<div className='block sm:hidden'>
<p className='font-medium flex items-center gap-1.5'>
{activity.status === 'synced' ? (
<>
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
</>
) : activity.status === 'mirrored' ? (
<>
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
</>
) : activity.status === 'failed' ? (
<>
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
</>
) : activity.status === 'syncing' ? (
<>
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
</>
) : activity.status === 'mirroring' ? (
<>
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
</>
) : activity.status === 'imported' ? (
<>
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
</>
) : (
<span>{activity.message}</span>
)}
</p>
</div>
{/* Desktop: Show status with icon and full message in tooltip */}
<div className='hidden sm:block'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className='font-medium flex items-center gap-1.5 cursor-help'>
{activity.status === 'synced' ? (
<>
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
</>
) : activity.status === 'mirrored' ? (
<>
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
</>
) : activity.status === 'failed' ? (
<>
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
</>
) : activity.status === 'syncing' ? (
<>
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
</>
) : activity.status === 'mirroring' ? (
<>
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
</>
) : activity.status === 'imported' ? (
<>
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
</>
) : (
<span className='truncate'>{activity.message}</span>
)}
</p>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
{formatDate(activity.timestamp)}
</p>
</div>
{activity.repositoryName && (
<p className='mb-2 text-sm text-muted-foreground'>
Repository: {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className='mb-2 text-sm text-muted-foreground'>
Organization: {activity.organizationName}
</p>
)}
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
{activity.repositoryName && (
<p className='text-sm text-muted-foreground truncate'>
<span className='font-medium'>Repo:</span> {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className='text-sm text-muted-foreground truncate'>
<span className='font-medium'>Org:</span> {activity.organizationName}
</p>
)}
</div>
{activity.details && (
<div className='mt-2'>
@@ -199,7 +292,7 @@ export default function ActivityList({
})
}
>
{isExpanded ? 'Hide Details' : 'Show Details'}
{isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
</Button>
{isExpanded && (

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react';
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +36,16 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -343,18 +353,225 @@ export function ActivityLog() {
setShowCleanupDialog(false);
};
// Check if any filters are active
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
setFilter({
searchTerm: filter.searchTerm,
status: '',
type: '',
name: '',
});
};
/* ------------------------------ UI ------------------------------ */
return (
<div className='flex flex-col gap-y-8'>
<div className='flex w-full flex-row items-center gap-4'>
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
{/* Mobile: Search bar with filter and action buttons */}
<div className="flex flex-col gap-2 sm:hidden">
<div className="flex items-center gap-2 w-full">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search activities..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Mobile Filter Drawer */}
<Drawer>
<DrawerTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-10 w-10 shrink-0"
>
<Filter className="h-4 w-4" />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeFilterCount}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle className="text-lg font-semibold">Filter Activities</DrawerTitle>
<DrawerDescription className="text-sm text-muted-foreground">
Narrow down your activity log
</DrawerDescription>
</DrawerHeader>
<div className="px-4 py-6 space-y-6 overflow-y-auto">
{/* Active filters summary */}
{hasActiveFilters && (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="text-sm font-medium">
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
</span>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 px-2 text-xs"
>
Clear all
</Button>
</div>
)}
{/* Status Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Status
{filter.status && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
</span>
)}
</label>
<Select
value={filter.status || 'all'}
onValueChange={(v) =>
setFilter((p) => ({
...p,
status: v === 'all' ? '' : (v as RepoStatus),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{['all', ...repoStatusEnum.options].map((s) => (
<SelectItem key={s} value={s}>
<span className="flex items-center gap-2">
{s !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
s === 'synced' ? 'bg-green-500' :
s === 'failed' ? 'bg-red-500' :
s === 'syncing' ? 'bg-blue-500' :
'bg-yellow-500'
}`} />
)}
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Type
{filter.type && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.type.charAt(0).toUpperCase() + filter.type.slice(1)}
</span>
)}
</label>
<Select
value={filter.type || 'all'}
onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
<span className="flex items-center gap-2">
{t !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
}`} />
)}
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Name Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Name
{filter.name && (
<span className="ml-auto text-xs text-muted-foreground">
Selected
</span>
)}
</label>
<ActivityNameCombobox
activities={activities}
value={filter.name || ''}
onChange={(name) => setFilter((p) => ({ ...p, name }))}
/>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
<DrawerClose asChild>
<Button className="w-full" size="sm">
Apply Filters
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="outline" className="w-full" size="sm">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Button
variant="outline"
size="icon"
onClick={() => fetchActivities(false)}
title="Refresh activity log"
className="h-10 w-10 shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Desktop: Original layout */}
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
{/* search input */}
<div className='relative flex-1'>
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type='text'
placeholder='Search activities...'
className='h-9 w-full rounded-md border border-input bg-background px-3 py-1 pl-8 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
type="text"
placeholder="Search activities..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({
@@ -365,27 +582,66 @@ export function ActivityLog() {
/>
</div>
{/* status select */}
<Select
value={filter.status || 'all'}
onValueChange={(v) =>
setFilter((p) => ({
...p,
status: v === 'all' ? '' : (v as RepoStatus),
}))
}
>
<SelectTrigger className='h-9 w-[140px] max-h-9'>
<SelectValue placeholder='All Status' />
</SelectTrigger>
<SelectContent>
{['all', ...repoStatusEnum.options].map((s) => (
<SelectItem key={s} value={s}>
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Filter controls */}
<div className="flex items-center gap-2">
{/* status select */}
<Select
value={filter.status || 'all'}
onValueChange={(v) =>
setFilter((p) => ({
...p,
status: v === 'all' ? '' : (v as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{['all', ...repoStatusEnum.options].map((s) => (
<SelectItem key={s} value={s}>
<span className="flex items-center gap-2">
{s !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
s === 'synced' ? 'bg-green-500' :
s === 'failed' ? 'bg-red-500' :
s === 'syncing' ? 'bg-blue-500' :
'bg-yellow-500'
}`} />
)}
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* type select */}
<Select
value={filter.type || 'all'}
onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
}
>
<SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
<span className="flex items-center gap-2">
{t !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
}`} />
)}
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* repo/org name combobox */}
<ActivityNameCombobox
@@ -394,64 +650,49 @@ export function ActivityLog() {
onChange={(name) => setFilter((p) => ({ ...p, name }))}
/>
{/* type select */}
<Select
value={filter.type || 'all'}
onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
}
>
<SelectTrigger className='h-9 w-[140px] max-h-9'>
<SelectValue placeholder='All Types' />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">
{/* export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10">
<Download className="h-4 w-4 mr-2" />
Export
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className='flex items-center gap-1'>
<Download className='mr-1 h-4 w-4' />
Export
<ChevronDown className='ml-1 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* refresh */}
<Button
variant="outline"
size="icon"
onClick={() => fetchActivities(false)}
title="Refresh activity log"
className="h-10 w-10"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* refresh */}
<Button
variant="outline"
size="icon"
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
title="Refresh activity log"
>
<RefreshCw className='h-4 w-4' />
</Button>
{/* cleanup all activities */}
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
className="text-destructive hover:text-destructive"
>
<Trash2 className='h-4 w-4' />
</Button>
{/* cleanup all activities */}
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
className="text-destructive hover:text-destructive h-10 w-10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* activity list */}
@@ -486,6 +727,26 @@ export function ActivityLog() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mobile FAB for Export - only visible on mobile */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
variant="default"
>
<Download className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="mb-2">
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -41,9 +41,14 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[180px] justify-between"
className="w-full sm:w-[180px] justify-between h-10"
>
{value ? value : "All Names"}
<span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All names"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -62,7 +67,7 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All Names
All names
</CommandItem>
{names.map((name) => (
<CommandItem

View File

@@ -4,50 +4,70 @@ 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 { 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<HTMLFormElement>) {
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),
});
await login(email, password);
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1000);
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsLoading(false);
}
}
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);
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 {
@@ -76,45 +96,182 @@ export function LoginForm() {
Log in to manage your GitHub to Gitea mirroring
</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
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 username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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 password"
disabled={isLoading}
/>
</div>
{isLoadingMethods ? (
<CardContent>
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</CardContent>
) : (
<>
{/* Show tabs only if multiple auth methods are available */}
{authMethods.sso.enabled && authMethods.emailPassword ? (
<Tabs defaultValue={getDefaultTab()} className="w-full">
<TabsList className="grid w-full grid-cols-2 mx-6" style={{ width: 'calc(100% - 3rem)' }}>
<TabsTrigger value="email">
<Mail className="h-4 w-4 mr-2" />
Email
</TabsTrigger>
<TabsTrigger value="sso">
<Globe className="h-4 w-4 mr-2" />
SSO
</TabsTrigger>
</TabsList>
<TabsContent value="email">
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
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 email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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 password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</TabsContent>
<TabsContent value="sso">
<CardContent>
<div className="space-y-4">
{authMethods.sso.providers.length > 0 && (
<>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
Sign in with your organization account
</p>
{authMethods.sso.providers.map(provider => (
<Button
key={provider.id}
variant="outline"
className="w-full"
onClick={() => handleSSOLogin(provider.domain)}
disabled={isLoading}
>
<Globe className="h-4 w-4 mr-2" />
Sign in with {provider.domain}
</Button>
))}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
</>
)}
<div>
<label htmlFor="sso-email" className="block text-sm font-medium mb-1">
Work Email
</label>
<input
id="sso-email"
type="email"
value={ssoEmail}
onChange={(e) => 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}
/>
<p className="text-xs text-muted-foreground mt-1">
We'll redirect you to your organization's SSO provider
</p>
</div>
</div>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleSSOLogin()}
disabled={isLoading || !ssoEmail}
>
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
</Button>
</CardFooter>
</TabsContent>
</Tabs>
) : (
// Single auth method - show email/password only
<>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
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 email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
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 password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</>
)}
</>
)}
<div className="px-6 pb-6 text-center">
<p className="text-sm text-muted-foreground">
Don't have an account? Contact your administrator.

View File

@@ -0,0 +1,10 @@
import { LoginForm } from './LoginForm';
import Providers from '@/components/layout/Providers';
export function LoginPage() {
return (
<Providers>
<LoginForm />
</Providers>
);
}

View File

@@ -5,21 +5,22 @@ 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<HTMLFormElement>) {
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;
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;
@@ -31,28 +32,15 @@ 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);
}
// 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 = '/';
}, 1500);
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -84,20 +72,6 @@ export function SignupForm() {
<CardContent>
<form id="signup-form" onSubmit={handleSignup}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
name="username"
type="text"
required
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 username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
@@ -110,6 +84,7 @@ export function SignupForm() {
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 email"
disabled={isLoading}
autoFocus
/>
</div>
<div>

View File

@@ -0,0 +1,10 @@
import { SignupForm } from './SignupForm';
import Providers from '@/components/layout/Providers';
export function SignupPage() {
return (
<Providers>
<SignupForm />
</Providers>
);
}

View File

@@ -98,6 +98,25 @@ export function AutomationSettings({
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Zap className="h-5 w-5" />
Automation & Maintenance
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 bg-muted hover:bg-muted/80 transition-colors">
<Info className="h-3 w-3" />
<span className="sr-only">Background operations info</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-2">
<p className="font-medium">Background Operations</p>
<p className="text-xs">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardTitle>
</CardHeader>
@@ -311,21 +330,6 @@ export function AutomationSettings({
</div>
</div>
</div>
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
<div className="flex gap-3">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Background Operations
</p>
<p className="text-xs text-blue-800 dark:text-blue-200/80">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -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;
@@ -44,7 +46,7 @@ export function ConfigTabs() {
token: '',
organization: 'github-mirrors',
visibility: 'public',
starredReposOrg: 'github',
starredReposOrg: 'starred',
preserveOrgStructure: false,
},
scheduleConfig: {
@@ -563,17 +565,17 @@ export function ConfigTabs() {
) : (
<div className="space-y-6">
{/* Header section */}
<div className="flex flex-row justify-between items-start">
<div className="flex flex-col md:flex-row justify-between gap-y-4 items-start">
<div className="flex flex-col gap-y-1.5">
<h1 className="text-2xl font-semibold leading-none tracking-tight">
Configuration Settings
Configuration
</h1>
<p className="text-sm text-muted-foreground">
Configure your GitHub and Gitea connections, and set up automatic
mirroring.
</p>
</div>
<div className="flex gap-x-4">
<div className="flex gap-x-4 w-full md:w-auto">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isGitHubConfigValid()}
@@ -584,6 +586,7 @@ export function ConfigTabs() {
? 'Import in progress'
: 'Import GitHub Data'
}
className="w-full md:w-auto"
>
{isSyncing ? (
<>
@@ -600,65 +603,71 @@ export function ConfigTabs() {
</div>
</div>
{/* Content section - Grid layout */}
<div className="space-y-6">
{/* GitHub & Gitea connections - Side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
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}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
{/* Automation & Maintenance - Full width */}
<div>
<TabsContent value="connections" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
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}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
</TabsContent>
<TabsContent value="automation" className="space-y-4">
<AutomationSettings
scheduleConfig={config.scheduleConfig}
cleanupConfig={config.cleanupConfig}
@@ -673,8 +682,12 @@ export function ConfigTabs() {
isAutoSavingSchedule={isAutoSavingSchedule}
isAutoSavingCleanup={isAutoSavingCleanup}
/>
</div>
</div>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -88,15 +88,17 @@ export function GitHubConfigForm({
return (
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
GitHub Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="outline"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
@@ -200,6 +202,17 @@ export function GitHubConfigForm({
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
{/* Mobile: Show button at bottom */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="sm:hidden w-full"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardContent>
</Card>

View File

@@ -124,7 +124,7 @@ export function GitHubMirrorSettings({
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="starred-repos"
@@ -145,10 +145,10 @@ export function GitHubMirrorSettings({
</div>
</div>
{/* Starred repos content selection - inline to prevent layout shift */}
{/* Starred repos content selection - responsive layout */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 hidden pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
@@ -156,7 +156,7 @@ export function GitHubMirrorSettings({
variant="outline"
size="sm"
disabled={!githubConfig.mirrorStarred}
className="h-8 text-xs font-normal min-w-[140px] justify-between"
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
>
<span>
{advancedOptions.skipStarredIssues ? (
@@ -325,7 +325,7 @@ export function GitHubMirrorSettings({
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="mirror-metadata"
@@ -346,10 +346,10 @@ export function GitHubMirrorSettings({
</div>
</div>
{/* Metadata multi-select - inline to prevent layout shift */}
{/* Metadata multi-select - responsive layout */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 hidden pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
@@ -357,7 +357,7 @@ export function GitHubMirrorSettings({
variant="outline"
size="sm"
disabled={!mirrorOptions.mirrorMetadata}
className="h-8 text-xs font-normal min-w-[140px] justify-between"
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
>
<span>
{(() => {

View File

@@ -44,11 +44,13 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
case "preserve":
newConfig.preserveOrgStructure = true;
newConfig.mirrorStrategy = "preserve";
newConfig.personalReposOrg = undefined; // Clear personal repos org in preserve mode
break;
case "single-org":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "single-org";
if (!newConfig.organization) {
// Reset to default if coming from mixed mode where it was personal repos org
if (config.mirrorStrategy === "mixed" || !newConfig.organization || newConfig.organization === "github-personal") {
newConfig.organization = "github-mirrors";
}
break;
@@ -60,8 +62,10 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
case "mixed":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "mixed";
if (!newConfig.organization) {
newConfig.organization = "github-mirrors";
// In mixed mode, organization field represents personal repos org
// Reset it to default if coming from single-org mode
if (config.mirrorStrategy === "single-org" || !newConfig.organization || newConfig.organization === "github-mirrors") {
newConfig.organization = "github-personal";
}
if (!newConfig.personalReposOrg) {
newConfig.personalReposOrg = "github-personal";
@@ -136,15 +140,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
return (
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
Gitea Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="outline"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
@@ -252,6 +258,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
if (onAutoSave) onAutoSave(newConfig);
}}
/>
{/* Mobile: Show button at bottom */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="sm:hidden w-full"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardContent>
</Card>
);

View File

@@ -104,7 +104,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
id="destinationOrg"
value={destinationOrg || ""}
onChange={(e) => onDestinationOrgChange(e.target.value)}
placeholder="github-mirrors"
placeholder={strategy === "mixed" ? "github-personal" : "github-mirrors"}
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -114,32 +114,6 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
}
</p>
</div>
) : strategy === "preserve" ? (
<div className="space-y-1">
<Label htmlFor="personalReposOrg" className="text-sm font-normal flex items-center gap-2">
Personal Repos Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Override where your personal repositories are mirrored (leave empty to use your username)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="personalReposOrg"
value={personalReposOrg || ""}
onChange={(e) => onPersonalReposOrgChange(e.target.value)}
placeholder="my-personal-mirrors"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Override destination for your personal repos
</p>
</div>
) : (
<div className="hidden md:block" />
)}
@@ -183,7 +157,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<Icon className="h-3.5 w-3.5" />
<span>{option.label}</span>
</div>
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity" />
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity hidden sm:inline-block" />
</button>
</TooltipTrigger>
<TooltipContent>

View File

@@ -24,7 +24,7 @@ const strategyConfig = {
preserve: {
title: "Preserve Structure",
icon: FolderTree,
description: "Keep the exact same organization structure as GitHub",
description: "Keep the exact same org structure as GitHub",
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950/20",
borderColor: "border-blue-200 dark:border-blue-900",
@@ -60,7 +60,7 @@ const strategyConfig = {
"mixed": {
title: "Mixed Mode",
icon: GitBranch,
description: "user repos in single org, org repos preserve structure",
description: "Personal repos in single org, org repos preserve structure",
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-50 dark:bg-orange-950/20",
borderColor: "border-orange-200 dark:border-orange-900",
@@ -281,7 +281,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
}) => {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building className="h-4 w-4" />
@@ -303,7 +303,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<span>Override Options</span>
</button>
</HoverCardTrigger>
<HoverCardContent side="left" align="start" className="w-[380px]">
<HoverCardContent side="bottom" align="start" className="w-[380px]">
<div className="space-y-3">
<div>
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>
@@ -371,15 +371,16 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
!isSelected && "border-muted"
)}
>
<div className="p-3">
<div className="flex items-center gap-3">
<div className="p-3 sm:p-4">
<div className="flex items-start gap-3">
<RadioGroupItem
value={key}
id={key}
className="mt-1"
/>
<div className={cn(
"rounded-lg p-2",
"rounded-lg p-2 flex-shrink-0",
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
)}>
<Icon className={cn(
@@ -388,38 +389,40 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
)} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-sm">{config.title}</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{config.description}
</p>
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-4 w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="font-medium text-sm">{config.title}</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{config.description}
</p>
</div>
</HoverCardContent>
</HoverCard>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex p-1 sm:p-1.5 hover:bg-muted rounded-md transition-colors cursor-help flex-shrink-0 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
</div>
</div>
</Card>

View File

@@ -0,0 +1,585 @@
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 { 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, Shield, Info } from 'lucide-react';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
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;
discoveryEndpoint?: string;
scopes?: string[];
pkce?: boolean;
};
samlConfig?: {
entryPoint: string;
cert: string;
callbackUrl?: string;
audience?: string;
wantAssertionsSigned?: boolean;
signatureAlgorithm?: string;
digestAlgorithm?: string;
identifierFormat?: string;
};
mapping?: {
id: string;
email: string;
emailVerified?: string;
name?: string;
image?: string;
firstName?: string;
lastName?: string;
};
createdAt: string;
updatedAt: string;
}
export function SSOSettings() {
const [providers, setProviders] = useState<SSOProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false);
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
// Form states for new provider
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
const [providerForm, setProviderForm] = useState({
// Common fields
issuer: '',
domain: '',
providerId: '',
organizationId: '',
// OIDC fields
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'],
pkce: true,
// SAML fields
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [providersRes, headerAuthStatus] = await Promise.all([
apiRequest<SSOProvider[]>('/auth/sso/register'),
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
]);
setProviders(providersRes);
setHeaderAuthEnabled(headerAuthStatus.enabled);
} 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<any>('/sso/discover', {
method: 'POST',
data: { issuer: providerForm.issuer },
});
setProviderForm(prev => ({
...prev,
authorizationEndpoint: discovered.authorizationEndpoint || '',
tokenEndpoint: discovered.tokenEndpoint || '',
jwksEndpoint: discovered.jwksEndpoint || '',
userInfoEndpoint: discovered.userInfoEndpoint || '',
discoveryEndpoint: discovered.discoveryEndpoint || `${providerForm.issuer}/.well-known/openid-configuration`,
domain: discovered.suggestedDomain || prev.domain,
}));
toast.success('OIDC configuration discovered successfully');
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDiscovering(false);
}
};
const createProvider = async () => {
try {
const requestData: any = {
providerId: providerForm.providerId,
issuer: providerForm.issuer,
domain: providerForm.domain,
organizationId: providerForm.organizationId || undefined,
providerType,
};
if (providerType === 'oidc') {
requestData.clientId = providerForm.clientId;
requestData.clientSecret = providerForm.clientSecret;
requestData.authorizationEndpoint = providerForm.authorizationEndpoint;
requestData.tokenEndpoint = providerForm.tokenEndpoint;
requestData.jwksEndpoint = providerForm.jwksEndpoint;
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
requestData.scopes = providerForm.scopes;
requestData.pkce = providerForm.pkce;
} else {
requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
requestData.audience = providerForm.audience || window.location.origin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm;
requestData.identifierFormat = providerForm.identifierFormat;
}
const newProvider = await apiRequest<SSOProvider>('/auth/sso/register', {
method: 'POST',
data: requestData,
});
setProviders([...providers, newProvider]);
setShowProviderDialog(false);
setProviderForm({
issuer: '',
domain: '',
providerId: '',
organizationId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
discoveryEndpoint: '',
scopes: ['openid', 'email', 'profile'],
pkce: true,
entryPoint: '',
cert: '',
callbackUrl: '',
audience: '',
wantAssertionsSigned: true,
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
toast.success('SSO provider created successfully');
} 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');
} catch (error) {
showErrorToast(error, toast);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header with status indicators */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Authentication & SSO</h3>
<p className="text-sm text-muted-foreground">
Configure how users authenticate with your application
</p>
</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm text-muted-foreground">
{providers.length} Provider{providers.length !== 1 ? 's' : ''} configured
</span>
</div>
</div>
{/* Authentication Methods Overview */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base">Active Authentication Methods</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Email & Password - Always enabled */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Email & Password</span>
<Badge variant="secondary" className="text-xs">Default</Badge>
</div>
<span className="text-xs text-muted-foreground">Always enabled</span>
</div>
{/* Header Authentication Status */}
{headerAuthEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Header Authentication</span>
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
</div>
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
</div>
)}
{/* SSO Providers Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm font-medium">SSO/OIDC Providers</span>
</div>
<span className="text-xs text-muted-foreground">
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
</span>
</div>
</div>
{/* Header Auth Info */}
{headerAuthEnabled && (
<Alert className="mt-4">
<Shield className="h-4 w-4" />
<AlertDescription className="text-xs">
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* SSO Providers */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>External Identity Providers</CardTitle>
<CardDescription>
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
</CardDescription>
</div>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add SSO Provider</DialogTitle>
<DialogDescription>
Configure an external identity provider for user authentication
</DialogDescription>
</DialogHeader>
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
</TabsList>
{/* Common Fields */}
<div className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="providerId">Provider ID</Label>
<Input
id="providerId"
value={providerForm.providerId}
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
placeholder="google-sso"
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain">Email Domain</Label>
<Input
id="domain"
value={providerForm.domain}
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
placeholder="example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="issuer">Issuer URL</Label>
<div className="flex gap-2">
<Input
id="issuer"
value={providerForm.issuer}
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
/>
{providerType === 'oidc' && (
<Button
variant="outline"
onClick={discoverOIDC}
disabled={isDiscovering}
>
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
<Input
id="organizationId"
value={providerForm.organizationId}
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
placeholder="org_123"
/>
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
</div>
</div>
<TabsContent value="oidc" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
value={providerForm.clientId}
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
value={providerForm.clientSecret}
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
<Input
id="authEndpoint"
value={providerForm.authorizationEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
placeholder="https://accounts.google.com/o/oauth2/auth"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
<Input
id="tokenEndpoint"
value={providerForm.tokenEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
placeholder="https://oauth2.googleapis.com/token"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="pkce"
checked={providerForm.pkce}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
/>
<Label htmlFor="pkce">Enable PKCE</Label>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}
</AlertDescription>
</Alert>
</TabsContent>
<TabsContent value="saml" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entryPoint">SAML Entry Point</Label>
<Input
id="entryPoint"
value={providerForm.entryPoint}
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
placeholder="https://idp.example.com/sso"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cert">X.509 Certificate</Label>
<Textarea
id="cert"
value={providerForm.cert}
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
rows={6}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="wantAssertionsSigned"
checked={providerForm.wantAssertionsSigned}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
/>
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
</div>
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>
Cancel
</Button>
<Button onClick={createProvider}>Create Provider</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{providers.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
</p>
<div className="mt-6">
<Button onClick={() => setShowProviderDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{providers.map(provider => (
<Card key={provider.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-semibold">{provider.providerId}</h4>
<Badge variant="outline" className="text-xs">
{provider.samlConfig ? 'SAML' : 'OIDC'}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{provider.domain}</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteProvider(provider.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium">Issuer</p>
<p className="text-muted-foreground break-all">{provider.issuer}</p>
</div>
{provider.oidcConfig && (
<div>
<p className="font-medium">Client ID</p>
<p className="text-muted-foreground font-mono break-all">{provider.oidcConfig.clientId}</p>
</div>
)}
{provider.samlConfig && (
<div>
<p className="font-medium">Entry Point</p>
<p className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</p>
</div>
)}
{provider.organizationId && (
<div className="col-span-2">
<p className="font-medium">Organization</p>
<p className="text-muted-foreground">{provider.organizationId}</p>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -57,8 +57,6 @@ export function Dashboard() {
}
setActivities((prevActivities) => [data, ...prevActivities]);
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -179,16 +177,16 @@ export function Dashboard() {
return isLoading || !connected ? (
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
</div>
<div className="flex gap-x-6 items-start">
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* Repository List Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="w-full lg:w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
@@ -201,7 +199,7 @@ export function Dashboard() {
</div>
{/* Recent Activity Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="w-full lg:w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
@@ -217,24 +215,24 @@ export function Dashboard() {
) : (
<div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
<StatusCard
title="Total Repositories"
title="Repositories"
value={repoCount}
icon={<GitFork className="h-4 w-4" />}
description="Repositories being mirrored"
description="Total in mirror queue"
/>
<StatusCard
title="Mirrored"
value={mirroredCount}
icon={<FlipHorizontal className="h-4 w-4" />}
description="Successfully mirrored"
description="Synced to Gitea"
/>
<StatusCard
title="Organizations"
value={orgCount}
icon={<Building2 className="h-4 w-4" />}
description="GitHub organizations"
description="From GitHub"
/>
<StatusCard
title="Last Sync"
@@ -254,11 +252,15 @@ export function Dashboard() {
/>
</div>
<div className="flex gap-x-6 items-start">
<RepositoryList repositories={repositories} />
<div className="flex flex-col lg:flex-row gap-6 items-start">
<div className="w-full lg:w-1/2">
<RepositoryList repositories={repositories} />
</div>
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
<div className="w-full lg:w-1/2">
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
</div>
</div>
</div>
);

View File

@@ -16,7 +16,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<a href="/activity">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<div className="flex flex-col divide-y divide-border">
{activities.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity</p>
@@ -31,7 +31,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
/>
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">
<p className="text-sm font-medium leading-none break-words">
{activity.message}
</p>
<p className="text-xs text-muted-foreground">

View File

@@ -54,7 +54,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
<a href="/repositories">View All</a>
</Button>
</CardHeader>
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
{repositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
@@ -71,11 +71,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
{repositories.map((repo, index) => (
<div
key={index}
className="flex items-center justify-between gap-x-4 py-4"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-x-4 py-4"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">{repo.name}</h4>
<div className="flex items-center flex-wrap gap-2">
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
{repo.isPrivate && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
Private
@@ -99,13 +99,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 sm:ml-auto">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-xs capitalize w-[3rem]">
<span className="text-xs capitalize w-[3rem] sm:w-auto">
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
{repo.status}
</span>

View File

@@ -7,13 +7,21 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
onNavigate?: (page: string) => void;
onMenuClick: () => void;
}
export function Header({ currentPage, onNavigate }: HeaderProps) {
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
const { user, logout, isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -54,39 +62,52 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
return (
<header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6">
<button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
/>
<span className="text-xl font-bold">Gitea Mirror</span>
</button>
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-2">
{/* Hamburger Menu Button - Mobile Only */}
<Button
variant="outline"
size="lg"
className="lg:hidden"
onClick={onMenuClick}
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
<button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
/>
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
</button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 sm:gap-4">
{showLiveButton && (
<Button
variant="outline"
size="lg"
className="flex items-center gap-2"
className="flex items-center gap-1.5 px-3 sm:px-4"
onClick={toggleLive}
title={getTooltip()}
>
<div className={`w-3 h-3 rounded-full ${
<div className={`size-4 sm:size-3 rounded-full ${
configLoading
? 'bg-yellow-400 animate-pulse'
: isLiveActive
@@ -95,7 +116,7 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
? 'bg-orange-400'
: 'bg-gray-500'
}`} />
<span>LIVE</span>
<span className="text-sm font-medium hidden sm:inline">LIVE</span>
</Button>
)}
@@ -104,19 +125,26 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<>
<Avatar>
<AvatarImage src="" alt="@shadcn" />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<Button variant="outline" size="lg" onClick={handleLogout}>
Logout
</Button>
</>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
<Avatar className="h-full w-full">
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
<AvatarFallback>
{(user.name || user.email || "U").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="outline" size="lg" asChild>
<Button variant="outline" size="sm" asChild>
<a href="/login">Login</a>
</Button>
)}

View File

@@ -44,6 +44,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
const { isLoading: configLoading } = useConfigStatus();
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
const [navigationKey, setNavigationKey] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
useRepoSync({
userId: user?.id,
@@ -99,10 +100,18 @@ function AppWithProviders({ page: initialPage }: AppProps) {
return (
<NavigationContext.Provider value={{ navigationKey }}>
<main className="flex min-h-screen flex-col">
<Header currentPage={currentPage} onNavigate={handleNavigation} />
<div className="flex flex-1">
<Sidebar onNavigate={handleNavigation} />
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
<Header
currentPage={currentPage}
onNavigate={handleNavigation}
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
/>
<div className="flex flex-1 relative">
<Sidebar
onNavigate={handleNavigation}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
{currentPage === "dashboard" && <Dashboard />}
{currentPage === "repositories" && <Repository />}
{currentPage === "organizations" && <Organization />}

View File

@@ -7,16 +7,17 @@ import { VersionInfo } from "./VersionInfo";
interface SidebarProps {
className?: string;
onNavigate?: (page: string) => void;
isOpen: boolean;
onClose: () => void;
}
export function Sidebar({ className, onNavigate }: SidebarProps) {
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
const [currentPath, setCurrentPath] = useState<string>("");
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
setCurrentPath(path);
console.log("Hydrated path:", path); // Should log now
}, []);
// Listen for URL changes (browser back/forward)
@@ -50,53 +51,77 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
const pageName = pageMap[href] || 'dashboard';
onNavigate?.(pageName);
// Close sidebar on mobile after navigation
if (window.innerWidth < 1024) {
onClose();
}
};
return (
<aside className={cn("w-64 border-r bg-background", className)}>
<div className="flex flex-col h-full pt-4">
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
{links.map((link, index) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
<>
{/* Mobile Backdrop */}
{isOpen && (
<div
className="fixed inset-0 backdrop-blur-sm z-40 lg:hidden"
onClick={onClose}
/>
)}
{/* Sidebar */}
<aside
className={cn(
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full lg:h-[calc(100vh-4.5rem)] transition-transform duration-200 ease-in-out lg:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full",
className
)}
>
<div className="flex flex-col h-full">
<nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3 pt-4 flex-shrink-0">
{links.map((link, index) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
return (
<button
key={index}
onClick={(e) => handleNavigation(link.href, e)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
return (
<button
key={index}
onClick={(e) => handleNavigation(link.href, e)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
{link.label}
</button>
);
})}
</nav>
<div className="flex-1 min-h-0" />
<div className="px-4 py-4 flex-shrink-0">
<div className="rounded-md bg-muted p-3 lg:p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
>
<Icon className="h-4 w-4" />
{link.label}
</button>
);
})}
</nav>
<div className="mt-auto px-4 py-4">
<div className="rounded-md bg-muted p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
Documentation
<ExternalLink className="h-3 w-3" />
</a>
Documentation
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
</a>
</div>
<VersionInfo />
</div>
<VersionInfo />
</div>
</div>
</aside>
</aside>
</>
);
}

View File

@@ -0,0 +1,72 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Coffee, Sparkles } from "lucide-react";
import { isSelfHostedMode } from "@/lib/deployment-mode";
export function SponsorCard() {
// Only show in self-hosted mode
if (!isSelfHostedMode()) {
return null;
}
return (
<div className="mt-auto p-4 border-t">
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border-purple-500/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-500" />
Support Development
</CardTitle>
<CardDescription className="text-xs">
Help us improve Gitea Mirror
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Gitea Mirror is open source and free. Your sponsorship helps us maintain and improve it.
</p>
<div className="space-y-2">
<Button
className="w-full h-8 text-xs"
size="sm"
asChild
>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-3 h-3 mr-2" />
Sponsor on GitHub
</a>
</Button>
<Button
className="w-full h-8 text-xs"
size="sm"
variant="outline"
asChild
>
<a
href="https://buymeacoffee.com/raylabs"
target="_blank"
rel="noopener noreferrer"
>
<Coffee className="w-3 h-3 mr-2" />
Buy us a coffee
</a>
</Button>
</div>
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Pro features available in hosted version
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { authClient } from '@/lib/auth-client';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast, Toaster } from 'sonner';
import { Shield, User, Mail, ChevronRight, AlertTriangle, Loader2 } from 'lucide-react';
import { isValidRedirectUri, parseRedirectUris } from '@/lib/utils/oauth-validation';
interface OAuthApplication {
id: string;
clientId: string;
name: string;
redirectURLs: string;
type: string;
}
interface ConsentRequest {
clientId: string;
scope: string;
state?: string;
redirectUri?: string;
}
export default function ConsentPage() {
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [application, setApplication] = useState<OAuthApplication | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConsentDetails();
}, []);
const loadConsentDetails = async () => {
try {
const params = new URLSearchParams(window.location.search);
const clientId = params.get('client_id');
const scope = params.get('scope');
const redirectUri = params.get('redirect_uri');
if (!clientId) {
setError('Invalid authorization request: missing client ID');
return;
}
// Fetch application details
const apps = await apiRequest<OAuthApplication[]>('/sso/applications');
const app = apps.find(a => a.clientId === clientId);
if (!app) {
setError('Invalid authorization request: unknown application');
return;
}
// Validate redirect URI if provided
if (redirectUri) {
const authorizedUris = parseRedirectUris(app.redirectURLs);
if (!isValidRedirectUri(redirectUri, authorizedUris)) {
setError('Invalid authorization request: unauthorized redirect URI');
return;
}
}
setApplication(app);
// Parse requested scopes
const requestedScopes = scope ? scope.split(' ').filter(s => s) : ['openid'];
setScopes(requestedScopes);
// By default, select all requested scopes
setSelectedScopes(new Set(requestedScopes));
} catch (error) {
console.error('Failed to load consent details:', error);
setError('Failed to load authorization details');
} finally {
setIsLoading(false);
}
};
const handleConsent = async (accept: boolean) => {
setIsSubmitting(true);
try {
const result = await authClient.oauth2.consent({
accept,
});
if (result.error) {
throw new Error(result.error.message || 'Consent failed');
}
// The consent method should handle the redirect
if (!accept) {
// If denied, redirect back to the application with error
const params = new URLSearchParams(window.location.search);
const redirectUri = params.get('redirect_uri');
if (redirectUri && application) {
// Validate redirect URI against authorized URIs
const authorizedUris = parseRedirectUris(application.redirectURLs);
if (isValidRedirectUri(redirectUri, authorizedUris)) {
try {
// Parse and reconstruct the URL to ensure it's safe
const url = new URL(redirectUri);
url.searchParams.set('error', 'access_denied');
// Safe to redirect - URI has been validated and sanitized
window.location.href = url.toString();
} catch (e) {
console.error('Failed to parse redirect URI:', e);
setError('Invalid redirect URI');
}
} else {
console.error('Unauthorized redirect URI:', redirectUri);
setError('Invalid redirect URI');
}
}
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsSubmitting(false);
}
};
const toggleScope = (scope: string) => {
// openid scope is always required
if (scope === 'openid') return;
const newSelected = new Set(selectedScopes);
if (newSelected.has(scope)) {
newSelected.delete(scope);
} else {
newSelected.add(scope);
}
setSelectedScopes(newSelected);
};
const getScopeDescription = (scope: string): { name: string; description: string; icon: any } => {
const scopeDescriptions: Record<string, { name: string; description: string; icon: any }> = {
openid: {
name: 'Basic Information',
description: 'Your user ID (required)',
icon: User,
},
profile: {
name: 'Profile Information',
description: 'Your name, username, and profile picture',
icon: User,
},
email: {
name: 'Email Address',
description: 'Your email address and verification status',
icon: Mail,
},
};
return scopeDescriptions[scope] || {
name: scope,
description: `Access to ${scope} information`,
icon: Shield,
};
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-2xl">Authorization Error</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full"
onClick={() => window.history.back()}
>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Authorize {application?.name}</CardTitle>
<CardDescription>
This application is requesting access to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm font-medium mb-2">Requested permissions:</p>
<div className="space-y-3">
{scopes.map(scope => {
const scopeInfo = getScopeDescription(scope);
const Icon = scopeInfo.icon;
const isRequired = scope === 'openid';
return (
<div key={scope} className="flex items-start space-x-3">
<Checkbox
id={scope}
checked={selectedScopes.has(scope)}
onCheckedChange={() => toggleScope(scope)}
disabled={isRequired || isSubmitting}
/>
<div className="flex-1">
<Label
htmlFor={scope}
className="flex items-center gap-2 font-medium cursor-pointer"
>
<Icon className="h-4 w-4" />
{scopeInfo.name}
{isRequired && (
<span className="text-xs text-muted-foreground">(required)</span>
)}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{scopeInfo.description}
</p>
</div>
</div>
);
})}
</div>
</div>
<Separator />
<div className="text-sm text-muted-foreground">
<p className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
You'll be redirected to {application?.type === 'web' ? 'the website' : 'the application'}
</p>
<p className="flex items-center gap-1 mt-1">
<ChevronRight className="h-3 w-3" />
You can revoke access at any time in your account settings
</p>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => handleConsent(false)}
disabled={isSubmitting}
>
Deny
</Button>
<Button
className="flex-1"
onClick={() => handleConsent(true)}
disabled={isSubmitting || selectedScopes.size === 0}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Authorizing...
</>
) : (
'Authorize'
)}
</Button>
</CardFooter>
</Card>
</div>
<Toaster />
</>
);
}

View File

@@ -63,12 +63,12 @@ export default function AddOrganizationDialog({
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
<Plus className="h-6 w-6" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
<DialogDescription>

View File

@@ -69,19 +69,19 @@ export function MirrorDestinationEditor({
};
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Building2 className="h-3 w-3" />
<span className="font-medium">{organizationName}</span>
<ArrowRight className="h-3 w-3" />
<div className={cn("flex items-center gap-2 w-full", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
<Building2 className="h-3 w-3 flex-shrink-0" />
<span className="font-medium truncate">{organizationName}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className={cn(
"font-medium",
"font-medium truncate",
hasOverride && "text-orange-600 dark:text-orange-400"
)}>
{effectiveDestination}
</span>
{hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400 flex-shrink-0">
custom
</Badge>
)}
@@ -92,11 +92,11 @@ export function MirrorDestinationEditor({
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
className="h-10 w-10 sm:h-6 sm:w-6 p-0 opacity-60 hover:opacity-100"
title="Edit mirror destination"
disabled={isUpdating || isLoading}
>
<Edit3 className="h-3 w-3" />
<Edit3 className="h-5 w-5 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react";
import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -27,6 +27,16 @@ import { toast } from "sonner";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -54,8 +64,6 @@ export function Organization() {
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -290,110 +298,351 @@ export function Organization() {
}
};
// Check if any filters are active
const hasActiveFilters = !!(filter.membershipRole || filter.status);
const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
setFilter({
searchTerm: filter.searchTerm,
membershipRole: "",
status: "",
});
};
return (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search Organizations..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
<div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
{/* Mobile: Search bar with filter button */}
<div className="flex items-center gap-2 w-full sm:hidden">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search organizations..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Mobile Filter Drawer */}
<Drawer>
<DrawerTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-10 w-10 shrink-0"
>
<Filter className="h-4 w-4" />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeFilterCount}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle className="text-lg font-semibold">Filter Organizations</DrawerTitle>
<DrawerDescription className="text-sm text-muted-foreground">
Narrow down your organization list
</DrawerDescription>
</DrawerHeader>
<div className="px-4 py-6 space-y-6 overflow-y-auto">
{/* Active filters summary */}
{hasActiveFilters && (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="text-sm font-medium">
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
</span>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 px-2 text-xs"
>
Clear all
</Button>
</div>
)}
{/* Role Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Role
{filter.membershipRole && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.membershipRole
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
)}
</label>
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
{["all", ...membershipRoleEnum.options].map((role) => (
<SelectItem key={role} value={role}>
<span className="flex items-center gap-2">
{role !== "all" && (
<span className={`h-2 w-2 rounded-full ${
role === "admin" ? "bg-purple-500" : "bg-blue-500"
}`} />
)}
{role === "all"
? "All roles"
: role
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Status
{filter.status && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
</span>
)}
</label>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{[
"all",
"imported",
"mirroring",
"mirrored",
"failed",
"syncing",
"synced",
].map((status) => (
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${
status === "synced" || status === "mirrored" ? "bg-green-500" :
status === "failed" ? "bg-red-500" :
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
"bg-yellow-500"
}`} />
)}
{status === "all"
? "All statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
<DrawerClose asChild>
<Button className="w-full" size="sm">
Apply Filters
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="outline" className="w-full" size="sm">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh organizations"
className="h-10 w-10 shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="default"
size="icon"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
title="Mirror all organizations"
className="h-10 w-10 shrink-0"
>
<FlipHorizontal className="h-4 w-4" />
</Button>
</div>
{/* Membership Role Filter */}
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
{["all", ...membershipRoleEnum.options].map((role) => (
<SelectItem key={role} value={role}>
{role === "all"
? "All Roles"
: role
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Desktop: Original layout */}
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search organizations..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Status Filter */}
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
{[
"all",
"imported",
"mirroring",
"mirrored",
"failed",
"syncing",
"synced",
].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Filter controls */}
<div className="flex items-center gap-2">
{/* Membership Role Filter */}
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
{["all", ...membershipRoleEnum.options].map((role) => (
<SelectItem key={role} value={role}>
<span className="flex items-center gap-2">
{role !== "all" && (
<span className={`h-2 w-2 rounded-full ${
role === "admin" ? "bg-purple-500" : "bg-blue-500"
}`} />
)}
{role === "all"
? "All roles"
: role
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh organizations"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* Status Filter */}
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{[
"all",
"imported",
"mirroring",
"mirrored",
"failed",
"syncing",
"synced",
].map((status) => (
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${
status === "synced" || status === "mirrored" ? "bg-green-500" :
status === "failed" ? "bg-red-500" :
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
"bg-yellow-500"
}`} />
)}
{status === "all"
? "All statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="default"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh organizations"
className="h-10 w-10"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="default"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
className="h-10 px-4"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
</div>
</div>
<OrganizationList
@@ -404,7 +653,9 @@ export function Organization() {
loadingOrgIds={loadingOrgIds}
onMirror={handleMirrorOrg}
onAddOrganization={() => setIsDialogOpen(true)}
onRefresh={() => fetchOrganizations(false)}
onRefresh={async () => {
await fetchOrganizations(false);
}}
/>
<AddOrganizationDialog

View File

@@ -127,9 +127,9 @@ export function OrganizationList({
}, [organizations, filter]);
return isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-[136px] w-full" />
<Skeleton key={i} className="h-[11.25rem] w-full" />
))}
</div>
) : filteredOrganizations.length === 0 ? (
@@ -161,7 +161,7 @@ export function OrganizationList({
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4 pb-20 sm:pb-0">
{filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
@@ -171,20 +171,33 @@ export function OrganizationList({
<Card
key={index}
className={cn(
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
"overflow-hidden p-4 sm:p-6 transition-all hover:shadow-lg hover:border-foreground/10 w-full",
isLoading && "opacity-75"
)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
{/* Mobile Layout */}
<div className="flex flex-col gap-3 sm:hidden">
{/* Header with org name and badges */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate"
>
{org.name}
</a>
</div>
<Badge variant={statusBadge.variant} className="flex-shrink-0">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
@@ -195,128 +208,175 @@ export function OrganizationList({
{org.membershipRole}
</span>
</div>
</div>
{/* Destination override section */}
<div className="mt-2">
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
{/* Destination override section */}
<div>
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden sm:block">
{/* Header with org icon, name, role badge and status */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer"
>
{org.name}
</a>
<Badge
variant={org.membershipRole === "member" ? "secondary" : "default"}
className="capitalize"
>
{org.membershipRole}
</Badge>
</div>
</div>
</div>
{/* Status badge */}
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
{StatusIcon && <StatusIcon className={cn(
"h-3.5 w-3.5",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
{/* Destination override section */}
<div className="mb-4">
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
</div>
{/* Repository statistics */}
<div className="mb-4">
<div className="flex items-center gap-4 text-sm">
<div>
<span className="font-semibold text-lg">{org.repositoryCount}</span>
<span className="text-muted-foreground ml-1">
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{/* Repository breakdown */}
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
</div>
) : (
<div className="flex items-center gap-3">
{org.publicRepositoryCount !== undefined && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground">
{org.publicRepositoryCount} public
</span>
</div>
)}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
<span className="text-muted-foreground">
{org.privateRepositoryCount} private
</span>
</div>
)}
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
<span className="text-muted-foreground">
{org.forkRepositoryCount} {org.forkRepositoryCount === 1 ? "fork" : "forks"}
</span>
</div>
)}
</div>
)}
</div>
</div>
<Badge variant={statusBadge.variant} className="ml-2">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-4">
<div className="flex items-center justify-between">
<span className="font-medium">
{org.repositoryCount}{" "}
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{/* Always render this section to prevent layout shift */}
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16" />
</>
) : (
<>
{org.publicRepositoryCount !== undefined ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
{org.publicRepositoryCount} public
</span>
) : null}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-orange-500" />
{org.privateRepositoryCount} private
</span>
) : null}
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-blue-500" />
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
</span>
) : null}
{/* Show a placeholder if no counts are available to maintain height */}
{org.publicRepositoryCount === undefined &&
org.privateRepositoryCount === undefined &&
org.forkRepositoryCount === undefined && (
<span className="invisible">Loading counts...</span>
)}
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
{/* Mobile Actions */}
<div className="flex flex-col gap-3 sm:hidden">
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="sm"
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting...
</>
) : (
"Mirror"
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="sm" disabled variant="outline">
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<Button size="default" disabled variant="outline" className="w-full h-10">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary">
<Check className="h-3 w-3 mr-2" />
<Button size="default" disabled variant="secondary" className="w-full h-10">
<Check className="h-4 w-4 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="sm"
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-2" />
Retry
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 justify-center">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
@@ -337,34 +397,166 @@ export function OrganizationList({
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
className="flex items-center justify-center gap-2"
>
<SiGitea className="h-4 w-4" />
<SiGitea className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">Gitea</span>
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<Button variant="outline" size="default" disabled title={tooltip} className="flex-1 h-10">
<SiGitea className="h-4 w-4" />
<span className="text-xs ml-2">Gitea</span>
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
className="flex items-center justify-center gap-2"
>
<SiGithub className="h-4 w-4" />
<SiGithub className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">GitHub</span>
</a>
</Button>
</div>
</div>
{/* Desktop Actions */}
<div className="hidden sm:flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting mirror...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring in progress...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary">
<Check className="h-4 w-4 mr-2" />
Successfully mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</div>
<div className="flex items-center gap-2">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (org.status === 'imported') {
tooltip = "Organization not yet mirrored to Gitea";
} else if (org.status === 'failed') {
tooltip = "Organization mirroring failed";
} else if (org.status === 'mirroring') {
tooltip = "Organization is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea organization not available";
}
return (
<div className="flex items-center border rounded-md">
<Button
variant="ghost"
size="sm"
asChild={!!giteaUrl}
disabled={!giteaUrl}
title={tooltip}
className="rounded-none rounded-l-md border-r"
>
{giteaUrl ? (
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
>
<SiGitea className="h-4 w-4 mr-2" />
Gitea
</a>
) : (
<>
<SiGitea className="h-4 w-4 mr-2" />
Gitea
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
asChild
className="rounded-none rounded-r-md"
>
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4 mr-2" />
GitHub
</a>
</Button>
</div>
);
})()}
</div>
</div>
</Card>
);
})}

View File

@@ -60,12 +60,12 @@ export default function AddRepositoryDialog({
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
<Plus className="h-6 w-6" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
<DialogHeader>
<DialogTitle>Add Repository</DialogTitle>
<DialogDescription>

View File

@@ -48,8 +48,8 @@ export function InlineDestinationEditor({
if (repository.organization) {
return repository.organization;
}
// For personal repos, check if personalReposOrg is configured
if (!repository.organization && giteaConfig?.personalReposOrg) {
// For personal repos, check if personalReposOrg is configured (but not in preserve mode)
if (!repository.organization && giteaConfig?.personalReposOrg && strategy !== 'preserve') {
return giteaConfig.personalReposOrg;
}
// Default to the gitea username or owner

View File

@@ -9,7 +9,7 @@ import {
type RepositoryApiResponse,
type RepoStatus,
} from "@/types/Repository";
import { apiRequest, showErrorToast } from "@/lib/utils";
import { apiRequest, showErrorToast, getStatusColor } from "@/lib/utils";
import {
Select,
SelectContent,
@@ -18,8 +18,18 @@ import {
SelectValue,
} from "../ui/select";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
@@ -71,8 +81,6 @@ export default function Repository() {
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -559,86 +567,343 @@ export default function Repository() {
const availableActions = getAvailableActions();
// Check if any filters are active
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
setFilter({
searchTerm: filter.searchTerm,
status: "",
organization: "",
owner: "",
});
};
return (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow min-w-[180px]">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
<div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
{/* Mobile: Search bar with filter button */}
<div className="flex items-center gap-2 w-full sm:hidden">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Mobile Filter Drawer */}
<Drawer>
<DrawerTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative h-10 w-10 shrink-0"
>
<Filter className="h-4 w-4" />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeFilterCount}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle className="text-lg font-semibold">Filter Repositories</DrawerTitle>
<DrawerDescription className="text-sm text-muted-foreground">
Narrow down your repository list
</DrawerDescription>
</DrawerHeader>
<div className="px-4 py-6 space-y-6 overflow-y-auto">
{/* Active filters summary */}
{hasActiveFilters && (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="text-sm font-medium">
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
</span>
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 px-2 text-xs"
>
Clear all
</Button>
</div>
)}
{/* Owner Combobox */}
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
{/* Owner Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Owner
{filter.owner && (
<span className="ml-auto text-xs text-muted-foreground">
Selected
</span>
)}
</label>
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
</div>
{/* Organization Combobox */}
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
{/* Organization Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Organization
{filter.organization && (
<span className="ml-auto text-xs text-muted-foreground">
Selected
</span>
)}
</label>
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
</div>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* Context-aware action buttons */}
{selectedRepoIds.size === 0 ? (
{/* Status Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Status
{filter.status && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
</span>
)}
</label>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
)}
{status === "all"
? "All statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
<DrawerClose asChild>
<Button className="w-full" size="sm">
Apply Filters
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="outline" className="w-full" size="sm">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
className="h-10 w-10 shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="default"
size="icon"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
title="Mirror all repositories"
className="h-10 w-10 shrink-0"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
<FlipHorizontal className="h-4 w-4" />
</Button>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
</div>
{/* Desktop: Original layout */}
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
<div className="relative flex-grow min-w-[180px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Owner Combobox */}
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
{/* Organization Combobox */}
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
{/* Filter controls in a responsive row */}
<div className="flex flex-row items-center gap-2">
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
)}
{status === "all"
? "All statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
className="h-10 w-10 shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Bulk actions on desktop - integrated into the same line */}
<div className="flex items-center gap-2 border-l pl-4">
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</>
)}
</div>
</div>
</div>
{/* Action buttons for mobile - only show when items are selected */}
{selectedRepoIds.size > 0 && (
<div className="flex items-center gap-2 flex-wrap sm:hidden">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
@@ -652,44 +917,45 @@ export default function Repository() {
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
<div className="flex gap-2 flex-wrap">
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
<span>Mirror </span>({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
)}
</div>
</div>
)}
{!isGitHubConfigured ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-md">
@@ -721,7 +987,9 @@ export default function Repository() {
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
onRefresh={() => fetchRepositories(false)}
onRefresh={async () => {
await fetchRepositories(false);
}}
/>
)}

View File

@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[160px] justify-between"
className="w-full sm:w-[160px] justify-between h-10"
>
{value ? value : placeholder}
<span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All owners"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandInput placeholder="Search owners..." />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandEmpty>No owners found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
@@ -54,7 +59,7 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All
All owners
</CommandItem>
{options.map((option) => (
<CommandItem
@@ -86,17 +91,22 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[160px] justify-between"
className="w-full sm:w-[160px] justify-between h-10"
>
{value ? value : placeholder}
<span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All organizations"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandInput placeholder="Search organizations..." />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
@@ -107,7 +117,7 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
}}
>
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All
All organizations
</CommandItem>
{options.map((option) => (
<CommandItem
@@ -128,4 +138,4 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
</PopoverContent>
</Popover>
);
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
@@ -17,6 +17,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface RepositoryTableProps {
repositories: Repository[];
@@ -166,338 +168,562 @@ export default function RepositoryTable({
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
// Mobile card layout for repository
const RepositoryCard = ({ repo }: { repo: Repository }) => {
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
const giteaUrl = getGiteaRepoUrl(repo);
return (
<Card className="mb-3">
<CardContent className="p-4">
<div className="flex flex-col gap-3">
{/* Header with checkbox and repo name */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-base truncate">{repo.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
</div>
</div>
</div>
{/* Repository details */}
<div className="space-y-2">
{/* Owner & Organization */}
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owner:</span>
<span className="truncate">{repo.owner}</span>
</div>
{repo.organization && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Org:</span>
<span className="truncate">{repo.organization}</span>
</div>
)}
{repo.destinationOrg && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Dest:</span>
<span className="truncate">{repo.destinationOrg}</span>
</div>
)}
</div>
{/* Status & Last Mirrored */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm font-medium capitalize">{repo.status}</span>
</div>
<span className="text-xs text-muted-foreground">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
{/* Primary action button */}
{(repo.status === "imported" || repo.status === "failed") && (
<Button
size="default"
variant="default"
onClick={() => repo.id && onMirror({ repoId: repo.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<FlipHorizontal className="h-4 w-4 mr-2 animate-spin" />
Mirroring...
</>
) : (
<>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror Repository
</>
)}
</Button>
)}
{(repo.status === "mirrored" || repo.status === "synced") && (
<Button
size="default"
variant="outline"
onClick={() => repo.id && onSync({ repoId: repo.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Sync Repository
</>
)}
</Button>
)}
{repo.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => repo.id && onRetry({ repoId: repo.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RotateCcw className="h-4 w-4 mr-2 animate-spin" />
Retrying...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
{/* External links */}
<div className="flex gap-2">
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
className="flex items-center justify-center gap-2"
>
<SiGithub className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">GitHub</span>
</a>
</Button>
{giteaUrl ? (
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title="View on Gitea"
className="flex items-center justify-center gap-2"
>
<SiGitea className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">Gitea</span>
</a>
</Button>
) : (
<Button variant="outline" size="default" disabled className="flex-1 h-10 min-w-0">
<SiGitea className="h-4 w-4" />
<span className="text-xs ml-2">Gitea</span>
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
};
return isLoading ? (
<div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
<div className="space-y-3 lg:space-y-0">
{/* Mobile skeleton */}
<div className="lg:hidden">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Skeleton className="h-4 w-4 mt-1" />
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-8" />
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
{/* Desktop skeleton */}
<div className="hidden lg:block border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
<Skeleton className="h-full w-full" />
Links
</div>
</div>
))}
</div>
) : filteredRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Configure your GitHub connection to start mirroring repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
})
}
>
Clear Filters
</Button>
) : (
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
)}
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 flex-[2.5]">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3 w-24 mt-1" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-20" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-20" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-24" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-16" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-8 w-20" />
</div>
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
))}
</div>
</div>
) : (
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
<div>
{hasAnyFilter && (
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Showing {filteredRepositories.length} of {repositories.length} repositories
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
organization: "",
owner: "",
})
}
>
Clear filters
</Button>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
)}
{/* table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-start flex-[1]">
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
/>
</div>
{/* Links */}
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
{(() => {
const giteaUrl = getGiteaRepoUrl(repo);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (repo.status === 'imported') {
tooltip = "Repository not yet mirrored to Gitea";
} else if (repo.status === 'failed') {
tooltip = "Repository mirroring failed";
} else if (repo.status === 'mirroring') {
tooltip = "Repository is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea repository not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
<span className="text-sm font-medium text-foreground">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
{hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span>
? "No repositories match the current filters"
: "No repositories found"}
</p>
</div>
) : (
<>
{/* Mobile card view */}
<div className="lg:hidden pb-20">
{/* Select all checkbox */}
<div className="flex items-center gap-3 mb-3 p-3 bg-muted/50 rounded-md">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
className="h-5 w-5"
/>
<span className="text-sm font-medium">
Select All ({filteredRepositories.length})
</span>
</div>
{/* Center - Live active indicator */}
{isLiveActive && (
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite'
}}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Live active
</span>
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite',
animationDelay: '1s'
}}
/>
{/* Repository cards */}
{filteredRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</div>
{/* Desktop table view */}
<div className="hidden lg:flex flex-col border rounded-md">
{/* Table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
{/* Table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-start flex-[1]">
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
/>
</div>
{/* Links */}
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
{(() => {
const giteaUrl = getGiteaRepoUrl(repo);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (repo.status === 'imported') {
tooltip = "Repository not yet mirrored to Gitea";
} else if (repo.status === 'failed') {
tooltip = "Repository mirroring failed";
} else if (repo.status === 'mirroring') {
tooltip = "Repository is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea repository not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
<span className="text-sm font-medium text-foreground">
{hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span>
</div>
{/* Center - Live active indicator */}
{isLiveActive && (
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite'
}}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Live active
</span>
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite',
animationDelay: '1s'
}}
/>
</div>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -531,7 +757,7 @@ function RepoActionButton({
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
label = "Mirror";
icon = <FlipHorizontal className="h-4 w-4 mr-1" />; // Don't change this icon to GitFork.
icon = <FlipHorizontal className="h-4 w-4 mr-1" />;
onClick = onMirror;
disabled ||= repo.status === "mirroring";
} else {
@@ -558,4 +784,4 @@ function RepoActionButton({
)}
</Button>
);
}
}

View File

@@ -0,0 +1,105 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Coffee, Zap } from "lucide-react";
import { isSelfHostedMode } from "@/lib/deployment-mode";
export function GitHubSponsors() {
// Only show in self-hosted mode
if (!isSelfHostedMode()) {
return null;
}
return (
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-purple-900 dark:text-purple-100">
<Heart className="w-5 h-5 text-pink-500" />
Support Gitea Mirror
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-purple-800 dark:text-purple-200">
Gitea Mirror is open source and free to use. If you find it helpful,
consider supporting the project!
</p>
<div className="space-y-2">
<Button
variant="default"
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
asChild
>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-4 h-4 mr-2" />
Become a Sponsor
</a>
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
asChild
>
<a
href="https://github.com/RayLabsHQ/gitea-mirror"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
<Button
variant="outline"
size="sm"
className="border-purple-300 hover:bg-purple-100 dark:border-purple-700 dark:hover:bg-purple-900"
asChild
>
<a
href="https://buymeacoffee.com/raylabs"
target="_blank"
rel="noopener noreferrer"
>
<Coffee className="w-4 h-4 mr-1" />
Buy Coffee
</a>
</Button>
</div>
</div>
<div className="text-xs text-purple-600 dark:text-purple-300 space-y-1">
<p className="flex items-center gap-1">
<Zap className="w-3 h-3" />
Your support helps maintain and improve the project
</p>
</div>
</CardContent>
</Card>
);
}
// Smaller inline sponsor button for headers/navbars
export function SponsorButton() {
if (!isSelfHostedMode()) {
return null;
}
return (
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/sponsors/RayLabsHQ"
target="_blank"
rel="noopener noreferrer"
>
<Heart className="w-4 h-4 mr-2" />
Sponsor
</a>
</Button>
);
}

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

147
src/hooks/useAuth-legacy.ts Normal file
View File

@@ -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<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
AuthContextType | undefined
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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;
}

View File

@@ -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<void>;
login: (email: string, password: string, username?: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
refreshUser: () => Promise<void>;
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
@@ -28,63 +29,32 @@ const AuthContext: Context<AuthContextType | undefined> = createContext<
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const betterAuthSession = useBetterAuthSession();
const [error, setError] = useState<string | null>(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();
// Derive user and session from Better Auth hook
const user = betterAuthSession.data?.user || null;
const session = betterAuthSession.data || null;
console.log("User data refreshed:", user);
// Don't do any redirects here - let the pages handle their own redirect logic
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) => {
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: "/",
});
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);
@@ -99,10 +69,19 @@ 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 for display name
callbackURL: "/",
});
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);
@@ -112,9 +91,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 {
@@ -122,10 +105,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: session as Session | null,
isLoading: isLoading || betterAuthSession.isPending,
error,
login,
register,
@@ -148,3 +138,6 @@ export function useAuth() {
}
return context;
}
// Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession };

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { apiRequest } from '@/lib/utils';
interface AuthMethods {
emailPassword: boolean;
sso: {
enabled: boolean;
providers: Array<{
id: string;
providerId: string;
domain: string;
}>;
};
oidc: {
enabled: boolean;
};
}
export function useAuthMethods() {
const [authMethods, setAuthMethods] = useState<AuthMethods>({
emailPassword: true,
sso: {
enabled: false,
providers: [],
},
oidc: {
enabled: false,
},
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadAuthMethods();
}, []);
const loadAuthMethods = async () => {
try {
// Check SSO providers
const providers = await apiRequest<any[]>('/auth/sso/register').catch(() => []);
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
setAuthMethods({
emailPassword: true, // Always enabled
sso: {
enabled: providers.length > 0,
providers: providers.map(p => ({
id: p.id,
providerId: p.providerId,
domain: p.domain,
})),
},
oidc: {
enabled: applications.length > 0,
},
});
} catch (error) {
// If we can't load auth methods, default to email/password only
console.error('Failed to load auth methods:', error);
} finally {
setIsLoading(false);
}
};
return { authMethods, isLoading };
}

View File

@@ -64,7 +64,6 @@ export const useSSE = ({
eventSource.onopen = () => {
setConnected(true);
setReconnectCount(0); // Reset reconnect counter on successful connection
console.log(`Connected to SSE for user: ${userId}`);
};
eventSource.onerror = (error) => {

View File

@@ -51,7 +51,6 @@ export function useRepoSync({
const sync = async () => {
try {
console.log("Attempting to sync...");
const response = await fetch("/api/job/schedule-sync-repo", {
method: "POST",
headers: {
@@ -68,7 +67,6 @@ export function useRepoSync({
await refreshUser(); // refresh user data to get latest sync times. this can be taken from the schedule-sync-repo response but might not be reliable in cases of errors
const result = await response.json();
console.log("Sync successful:", result);
return result;
} catch (error) {
console.error("Sync failed:", error);

35
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
export const authClient = createAuthClient({
// The base URL is optional when running on the same domain
// Better Auth will use the current domain by default
plugins: [
oidcClient(),
ssoClient(),
],
});
// Export commonly used methods for convenience
export const {
signIn,
signUp,
signOut,
useSession,
sendVerificationEmail,
resetPassword,
requestPasswordReset,
getSession
} = authClient;
// Export types - directly use the types from better-auth
export type Session = BetterAuthSession & {
user: BetterAuthUser & {
username?: string | null;
};
};
export type AuthUser = BetterAuthUser & {
username?: string | null;
};

135
src/lib/auth-header.ts Normal file
View File

@@ -0,0 +1,135 @@
import { db, users } from "./db";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export interface HeaderAuthConfig {
enabled: boolean;
userHeader: string;
emailHeader?: string;
nameHeader?: string;
autoProvision: boolean;
allowedDomains?: string[];
}
// Default configuration - DISABLED by default
export const defaultHeaderAuthConfig: HeaderAuthConfig = {
enabled: false,
userHeader: "X-Authentik-Username", // Common header name
emailHeader: "X-Authentik-Email",
nameHeader: "X-Authentik-Name",
autoProvision: false,
allowedDomains: [],
};
// Get header auth config from environment or database
export function getHeaderAuthConfig(): HeaderAuthConfig {
// Check environment variables for header auth config
const envConfig: Partial<HeaderAuthConfig> = {
enabled: process.env.HEADER_AUTH_ENABLED === "true",
userHeader: process.env.HEADER_AUTH_USER_HEADER || defaultHeaderAuthConfig.userHeader,
emailHeader: process.env.HEADER_AUTH_EMAIL_HEADER || defaultHeaderAuthConfig.emailHeader,
nameHeader: process.env.HEADER_AUTH_NAME_HEADER || defaultHeaderAuthConfig.nameHeader,
autoProvision: process.env.HEADER_AUTH_AUTO_PROVISION === "true",
allowedDomains: process.env.HEADER_AUTH_ALLOWED_DOMAINS?.split(",").map(d => d.trim()),
};
return {
...defaultHeaderAuthConfig,
...envConfig,
};
}
// Check if header authentication is enabled
export function isHeaderAuthEnabled(): boolean {
const config = getHeaderAuthConfig();
return config.enabled === true;
}
// Extract user info from headers
export function extractUserFromHeaders(headers: Headers): {
username?: string;
email?: string;
name?: string;
} | null {
const config = getHeaderAuthConfig();
if (!config.enabled) {
return null;
}
const username = headers.get(config.userHeader);
const email = config.emailHeader ? headers.get(config.emailHeader) : undefined;
const name = config.nameHeader ? headers.get(config.nameHeader) : undefined;
if (!username) {
return null;
}
// If allowed domains are configured, check email domain
if (config.allowedDomains && config.allowedDomains.length > 0 && email) {
const domain = email.split("@")[1];
if (!config.allowedDomains.includes(domain)) {
console.warn(`Header auth rejected: email domain ${domain} not in allowed list`);
return null;
}
}
return { username, email, name };
}
// Find or create user from header auth
export async function authenticateWithHeaders(headers: Headers) {
const userInfo = extractUserFromHeaders(headers);
if (!userInfo || !userInfo.username) {
return null;
}
const config = getHeaderAuthConfig();
// Try to find existing user by username or email
let existingUser = await db
.select()
.from(users)
.where(eq(users.username, userInfo.username))
.limit(1);
if (existingUser.length === 0 && userInfo.email) {
existingUser = await db
.select()
.from(users)
.where(eq(users.email, userInfo.email))
.limit(1);
}
if (existingUser.length > 0) {
return existingUser[0];
}
// If auto-provisioning is disabled, don't create new users
if (!config.autoProvision) {
console.warn(`Header auth: User ${userInfo.username} not found and auto-provisioning is disabled`);
return null;
}
// Create new user if auto-provisioning is enabled
try {
const newUser = {
id: nanoid(),
username: userInfo.username,
email: userInfo.email || `${userInfo.username}@header-auth.local`,
emailVerified: true, // Trust the auth provider
name: userInfo.name || userInfo.username,
createdAt: new Date(),
updatedAt: new Date(),
};
await db.insert(users).values(newUser);
console.log(`Header auth: Auto-provisioned new user ${userInfo.username}`);
return newUser;
} catch (error) {
console.error("Failed to auto-provision user from header auth:", error);
return null;
}
}

114
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { oidcProvider } from "better-auth/plugins";
import { sso } from "@better-auth/sso";
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:4321",
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 }) => {
// 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: false,
input: false, // Don't show in signup form - we'll derive from email
}
},
},
// Plugins configuration
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it
// For now, we'll use dynamic registration
// Customize user info claims based on scopes
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
if (scopes.includes("profile")) {
claims.username = user.username;
}
return claims;
},
}),
// SSO plugin - allows users to authenticate with external OIDC providers
sso({
// Provision new users when they sign in with SSO
provisionUser: async ({ user }: { user: any, userInfo: any }) => {
// Derive username from email if not provided
const username = user.name || user.email?.split('@')[0] || 'user';
// Update user in database if needed
await db.update(users)
.set({ username })
.where(eq(users.id, user.id))
.catch(() => {}); // Ignore errors if user doesn't exist yet
},
// Organization provisioning settings
organizationProvisioning: {
disabled: false,
defaultRole: "member",
getRole: async ({ user, userInfo }: { user: any, userInfo: any }) => {
// Check if user has admin attribute from SSO provider
const isAdmin = userInfo.attributes?.role === 'admin' ||
userInfo.attributes?.groups?.includes('admins');
return isAdmin ? "admin" : "member";
},
},
// Override user info with provider data by default
defaultOverrideUserInfo: true,
// Allow implicit sign up for new users
disableImplicitSignUp: false,
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:4321",
],
});
// Export type for use in other parts of the app
export type Auth = typeof auth;

View File

@@ -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",

102
src/lib/db/adapter.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Database adapter for SQLite
* For the self-hosted version of Gitea Mirror
*/
import { drizzle as drizzleSqlite } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import * as schema from './schema';
export type DatabaseClient = ReturnType<typeof createDatabase>;
/**
* Create SQLite database connection
*/
export function createDatabase() {
const dbPath = process.env.DATABASE_PATH || './data/gitea-mirror.db';
// Ensure directory exists
const fs = require('fs');
const path = require('path');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create SQLite connection
const sqlite = new Database(dbPath);
// Enable foreign keys and WAL mode for better performance
sqlite.exec('PRAGMA foreign_keys = ON');
sqlite.exec('PRAGMA journal_mode = WAL');
sqlite.exec('PRAGMA synchronous = NORMAL');
sqlite.exec('PRAGMA cache_size = -2000'); // 2MB cache
sqlite.exec('PRAGMA temp_store = MEMORY');
// Create Drizzle instance with SQLite
const db = drizzleSqlite(sqlite, {
schema,
logger: process.env.NODE_ENV === 'development',
});
return {
db,
client: sqlite,
type: 'sqlite' as const,
// Helper methods
async close() {
sqlite.close();
},
async healthCheck() {
try {
sqlite.query('SELECT 1').get();
return true;
} catch {
return false;
}
},
async transaction<T>(fn: (tx: any) => Promise<T>) {
return db.transaction(fn);
},
};
}
// Create singleton instance
let dbInstance: DatabaseClient | null = null;
/**
* Get database instance (singleton)
*/
export function getDatabase(): DatabaseClient {
if (!dbInstance) {
dbInstance = createDatabase();
}
return dbInstance;
}
/**
* Close database connection
*/
export async function closeDatabase() {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
}
}
// Export convenience references
export const { db, client, type: dbType } = getDatabase();
// Re-export schema for convenience
export * from './schema';
/**
* Database migration utilities
*/
export async function runMigrations() {
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
await migrate(db, { migrationsFolder: './drizzle' });
}

View File

@@ -1,489 +1,85 @@
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");
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Skip database initialization in test environment
let db: ReturnType<typeof drizzle>;
const dbPath = path.join(dataDir, "gitea-mirror.db");
if (process.env.NODE_ENV !== "test") {
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
const dbPath = path.join(dataDir, "gitea-mirror.db");
// Create SQLite database instance using Bun's native driver
let sqlite: Database;
try {
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
}
// Ensure all required tables exist
ensureTablesExist(sqlite);
// Run migrations
runMigrations(sqlite);
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
/**
* Run database migrations
*/
function runMigrations(db: Database) {
// Create SQLite database instance using Bun's native driver
let sqlite: Database;
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');
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");
}
// 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");
}
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
} catch (error) {
console.error("Error running migrations:", error);
// Don't throw - migrations should be non-breaking
console.error("Error opening database:", error);
throw error;
}
}
/**
* Ensure all required tables exist in the database
*/
function ensureTablesExist(db: Database) {
const requiredTables = [
"users",
"configs",
"repositories",
"organizations",
"mirror_jobs",
"events",
];
// Create drizzle instance with the SQLite client
db = drizzle({ client: sqlite });
for (const table of requiredTables) {
/**
* Run Drizzle migrations
*/
function runDrizzleMigrations() {
try {
// Check if table exists
const result = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`).get();
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 (!result) {
console.warn(`⚠️ Table '${table}' is missing. Creating it now...`);
createTable(db, table);
console.log(`✅ Table '${table}' created successfully`);
if (!migrationsTableExists) {
console.log("📦 First time setup - running initial migrations...");
}
// Run migrations using Drizzle migrate function
migrate(db, { migrationsFolder: "./drizzle" });
console.log("✅ Database migrations completed successfully");
} catch (error) {
console.error(`❌ Error checking/creating table '${table}':`, error);
console.error("❌ Error running migrations:", error);
throw error;
}
}
// Run Drizzle migrations after db is initialized
runDrizzleMigrations();
}
/**
* 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;
export { db };
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}`);
}
}
// 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<z.infer<typeof githubSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(["*"]),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default([]),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleSchema>>()
.notNull(),
cleanupConfig: text("cleanup_config", { mode: "json" })
.$type<z.infer<typeof cleanupSchema>>()
.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<string[]>(),
completedItemIds: text("completed_item_ids", { mode: "json" }).$type<string[]>().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,
sessions,
accounts,
verificationTokens,
oauthApplications,
oauthAccessTokens,
oauthConsent,
ssoProviders
} from "./schema";

View File

@@ -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
);

View File

@@ -1,182 +1,623 @@
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
email: z.string().email(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
id: z.string(),
username: z.string(),
password: z.string(),
email: z.email(),
emailVerified: z.boolean().default(false),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type User = z.infer<typeof userSchema>;
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", "mixed"]).default("preserve"),
defaultOrg: z.string().optional(),
});
export const giteaConfigSchema = z.object({
url: z.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"),
// Mirror options
mirrorReleases: z.boolean().default(false),
mirrorMetadata: z.boolean().default(false),
mirrorIssues: z.boolean().default(false),
mirrorPullRequests: z.boolean().default(false),
mirrorLabels: z.boolean().default(false),
mirrorMilestones: z.boolean().default(false),
});
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),
retentionDays: z.number().default(604800), // 7 days in seconds
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<typeof configSchema>;
// 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),
url: z.string().url(),
cloneUrl: z.string().url(),
owner: z.string().min(1),
organization: z.string().optional(),
id: z.string(),
userId: z.string(),
configId: z.string(),
name: z.string(),
fullName: z.string(),
url: z.url(),
cloneUrl: z.url(),
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",
"syncing",
"synced",
])
.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<typeof repositorySchema>;
// 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",
"syncing",
"synced",
])
.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<typeof mirrorJobSchema>;
// 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",
"syncing",
"synced",
])
.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<typeof organizationSchema>;
// 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<typeof eventSchema>;
// ===== Drizzle Table Definitions =====
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
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", {
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<z.infer<typeof githubConfigSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaConfigSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(sql`'["*"]'`),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default(sql`'[]'`),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleConfigSchema>>()
.notNull(),
cleanupConfig: text("cleanup_config", { mode: "json" })
.$type<z.infer<typeof cleanupConfigSchema>>()
.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<string[]>(),
completedItemIds: text("completed_item_ids", { mode: "json" })
.$type<string[]>()
.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),
};
});
// ===== 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(),
accountId: text("account_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
providerId: text("provider_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" }),
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 {
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),
};
});
// 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),
};
});
// ===== OIDC Provider Tables =====
// OAuth Applications table
export const oauthApplications = sqliteTable("oauth_applications", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientSecret: text("client_secret").notNull(),
name: text("name").notNull(),
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
metadata: text("metadata"), // JSON string
type: text("type").notNull(), // web, mobile, etc
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
userId: text("user_id"), // Optional - owner of the application
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
clientIdIdx: index("idx_oauth_applications_client_id").on(table.clientId),
userIdIdx: index("idx_oauth_applications_user_id").on(table.userId),
};
});
// OAuth Access Tokens table
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
id: text("id").primaryKey(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
clientId: text("client_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
scopes: text("scopes").notNull(), // Comma-separated list
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
accessTokenIdx: index("idx_oauth_access_tokens_access_token").on(table.accessToken),
userIdIdx: index("idx_oauth_access_tokens_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_access_tokens_client_id").on(table.clientId),
};
});
// OAuth Consent table
export const oauthConsent = sqliteTable("oauth_consent", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
clientId: text("client_id").notNull(),
scopes: text("scopes").notNull(), // Comma-separated list
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
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_oauth_consent_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_consent_client_id").on(table.clientId),
userClientIdx: index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
};
});
// ===== SSO Provider Tables =====
// SSO Providers table
export const ssoProviders = sqliteTable("sso_providers", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
domain: text("domain").notNull(),
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
userId: text("user_id").notNull(), // Admin who created this provider
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
providerIdIdx: index("idx_sso_providers_provider_id").on(table.providerId),
domainIdx: index("idx_sso_providers_domain").on(table.domain),
issuerIdx: index("idx_sso_providers_issuer").on(table.issuer),
};
});
// Export type definitions
export type User = z.infer<typeof userSchema>;
export type Config = z.infer<typeof configSchema>;
export type Repository = z.infer<typeof repositorySchema>;
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
export type Organization = z.infer<typeof organizationSchema>;
export type Event = z.infer<typeof eventSchema>;

View File

@@ -0,0 +1,22 @@
/**
* Deployment mode utilities
* Supports both self-hosted and hosted versions
*/
export const DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || 'selfhosted';
export const isSelfHostedMode = () => DEPLOYMENT_MODE === 'selfhosted';
export const isHostedMode = () => DEPLOYMENT_MODE === 'hosted';
/**
* Feature flags for self-hosted version
*/
export const features = {
// Core features available
githubSync: true,
giteaMirroring: true,
scheduling: true,
multiUser: true,
githubSponsors: true,
unlimitedRepos: true,
};

Some files were not shown because too many files have changed in this diff Show More