Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8ca7c3b8 | ||
|
|
f2c7728394 | ||
|
|
dcdb06ac39 | ||
|
|
9fa10dae00 | ||
|
|
99dd501a52 | ||
|
|
d4aa665873 | ||
|
|
0244133e7b | ||
|
|
6ea5e9efb0 | ||
|
|
8d7ca8dd8f | ||
|
|
8d2919717f | ||
|
|
1e06e2bd4b | ||
|
|
67080a7ce9 | ||
|
|
9d5db86bdf | ||
|
|
3458891511 | ||
|
|
d388f2e691 | ||
|
|
7bd862606b | ||
|
|
251baeb1aa | ||
|
|
e6a31512ac | ||
|
|
4430625319 | ||
|
|
236bef543b | ||
|
|
03bad9a0c0 | ||
|
|
f60ccfc9f1 | ||
|
|
cad77320f3 | ||
|
|
744064f3aa | ||
|
|
f83711ecd6 | ||
|
|
bde1f7b5d6 | ||
|
|
39bfb1e2d1 | ||
|
|
2140f75436 | ||
|
|
a5a827c85f | ||
|
|
0af2626201 | ||
|
|
d48981a8c4 | ||
|
|
948250deef | ||
|
|
beedbaf9a4 | ||
|
|
7cc4aa87f2 | ||
|
|
ae41c4e2ea | ||
|
|
58c63095b1 | ||
|
|
938a909787 | ||
|
|
fad78516ef | ||
|
|
7cb414c7cb | ||
|
|
6cfe43932f | ||
|
|
b838310872 | ||
|
|
46cf117bdf | ||
|
|
9301cc321c | ||
|
|
9c17e5c240 | ||
|
|
a0b9d852bf | ||
|
|
eed26e4368 | ||
|
|
e4f79720d4 | ||
|
|
11dc299f12 | ||
|
|
638554c93a | ||
|
|
d6de431902 | ||
|
|
a320dc38ad | ||
|
|
97997bd1c0 | ||
|
|
316d263298 | ||
|
|
c891b8cf49 | ||
|
|
b55d6a5629 | ||
|
|
bb1842bc10 | ||
|
|
4958b8b9ec | ||
|
|
aedf0c23b8 | ||
|
|
b4937d1e01 | ||
|
|
498968695f | ||
|
|
d0e8e754a7 | ||
|
|
c95a501974 | ||
|
|
fd8f782f34 | ||
|
|
02ff865e4b | ||
|
|
df9da165c8 | ||
|
|
180f300752 | ||
|
|
472f67a6ae | ||
|
|
6270907e70 | ||
|
|
1deaae4d34 | ||
|
|
b984ff9af4 | ||
|
|
24bd0aefe6 | ||
|
|
6155e39360 |
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker build:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
67
.env.example
@@ -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,15 +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
|
||||
|
||||
# Optional TLS/SSL Configuration
|
||||
# Option 1: Mount custom CA certificates in ./certs directory as .crt files
|
||||
# The container will automatically combine them into a CA bundle
|
||||
# Option 2: Mount your system CA bundle at /etc/ssl/certs/ca-certificates.crt
|
||||
# See docker-compose.yml for volume mount examples
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing, disables TLS verification
|
||||
# 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
@@ -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']
|
||||
BIN
.github/assets/activity.png
vendored
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 854 KiB |
BIN
.github/assets/activity_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
.github/assets/configuration.png
vendored
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 950 KiB |
BIN
.github/assets/configuration_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
.github/assets/dashboard_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
.github/assets/organisation.png
vendored
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
.github/assets/organisation_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
.github/assets/organisations.png
vendored
|
Before Width: | Height: | Size: 784 KiB |
BIN
.github/assets/repositories_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 227 KiB |
1
.gitignore
vendored
@@ -31,3 +31,4 @@ certs/*.crt
|
||||
certs/*.pem
|
||||
certs/*.cer
|
||||
!certs/README.md
|
||||
|
||||
|
||||
74
CHANGELOG.md
@@ -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
@@ -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
@@ -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! 🎉
|
||||
@@ -31,6 +31,7 @@ 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
|
||||
|
||||
195
README.md
@@ -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.1.1
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
# Session secret (auto-generated if not set)
|
||||
BETTER_AUTH_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
|
||||
@@ -134,7 +201,85 @@ bun run build
|
||||
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||
- **Backend**: Bun runtime, SQLite, Drizzle ORM
|
||||
- **APIs**: GitHub (Octokit), Gitea REST API
|
||||
- **Auth**: JWT tokens with bcryptjs password hashing
|
||||
- **Auth**: Better Auth with session-based authentication
|
||||
|
||||
## 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` if not set
|
||||
|
||||
### Password Security
|
||||
- User passwords are securely hashed by Better Auth
|
||||
- Never stored in plaintext
|
||||
- Secure cookie-based session management
|
||||
|
||||
## 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
|
||||
|
||||
@@ -144,10 +289,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
@@ -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
@@ -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=="],
|
||||
|
||||
337
certs/README.md
@@ -1,149 +1,236 @@
|
||||
# Custom CA Certificate Support
|
||||
# CA Certificates Configuration
|
||||
|
||||
This guide explains how to configure Gitea Mirror to work with self-signed certificates or custom Certificate Authorities (CAs).
|
||||
|
||||
> **📁 This is the certs directory!** Place your `.crt` certificate files directly in this directory and they will be automatically loaded when the Docker container starts.
|
||||
This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
|
||||
|
||||
## Overview
|
||||
|
||||
When connecting to a Gitea instance that uses self-signed certificates or certificates from a private CA, you need to configure the application to trust these certificates. Gitea Mirror supports mounting custom CA certificates that will be automatically configured for use.
|
||||
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates.
|
||||
|
||||
## Configuration Steps
|
||||
## Common SSL/TLS Errors
|
||||
|
||||
### 1. Prepare Your CA Certificates
|
||||
If you encounter any of these errors, you need to configure CA certificates:
|
||||
|
||||
You're already in the right place! Simply copy your CA certificate(s) into this `certs` directory with `.crt` extension:
|
||||
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
|
||||
- `SELF_SIGNED_CERT_IN_CHAIN`
|
||||
- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
|
||||
- `CERT_UNTRUSTED`
|
||||
- `unable to verify the first certificate`
|
||||
|
||||
```bash
|
||||
# From the project root:
|
||||
cp /path/to/your/ca-certificate.crt ./certs/
|
||||
## Configuration by Deployment Method
|
||||
|
||||
# Or if you're already in the certs directory:
|
||||
cp /path/to/your/ca-certificate.crt .
|
||||
```
|
||||
### Docker
|
||||
|
||||
You can add multiple CA certificates - they will all be combined into a single bundle.
|
||||
#### Method 1: Volume Mount (Recommended)
|
||||
|
||||
### 2. Mount Certificates in Docker
|
||||
|
||||
Edit your `docker-compose.yml` file to mount the certificates. You have two options:
|
||||
|
||||
**Option 1: Mount individual certificates from certs directory**
|
||||
```yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
# ... other configuration ...
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
- ./certs:/app/certs:ro # Mount CA certificates directory
|
||||
```
|
||||
|
||||
**Option 2: Mount system CA bundle (if your CA is already installed system-wide)**
|
||||
```yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
# ... other configuration ...
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
```
|
||||
|
||||
> **Note**: Use Option 2 if you've already added your CA certificate to your system's certificate store using `update-ca-certificates` or similar commands.
|
||||
|
||||
> **System CA Bundle Locations**:
|
||||
> - Debian/Ubuntu: `/etc/ssl/certs/ca-certificates.crt`
|
||||
> - RHEL/CentOS/Fedora: `/etc/pki/tls/certs/ca-bundle.crt`
|
||||
> - Alpine Linux: `/etc/ssl/certs/ca-certificates.crt`
|
||||
> - macOS: `/etc/ssl/cert.pem`
|
||||
|
||||
### 3. Start the Container
|
||||
|
||||
Start or restart your container:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The container will automatically:
|
||||
1. Detect any `.crt` files in `/app/certs` (Option 1) OR detect mounted system CA bundle (Option 2)
|
||||
2. For Option 1: Combine certificates into a CA bundle
|
||||
3. Configure Node.js to use these certificates via `NODE_EXTRA_CA_CERTS`
|
||||
|
||||
You should see log messages like:
|
||||
|
||||
**For Option 1 (individual certificates):**
|
||||
```
|
||||
Custom CA certificates found, configuring Node.js to use them...
|
||||
Adding certificate: my-ca.crt
|
||||
NODE_EXTRA_CA_CERTS set to: /app/certs/ca-bundle.crt
|
||||
```
|
||||
|
||||
**For Option 2 (system CA bundle):**
|
||||
```
|
||||
System CA bundle mounted, configuring Node.js to use it...
|
||||
NODE_EXTRA_CA_CERTS set to: /etc/ssl/certs/ca-certificates.crt
|
||||
```
|
||||
|
||||
## Testing & Troubleshooting
|
||||
|
||||
### Disable TLS Verification (Testing Only)
|
||||
|
||||
For testing purposes only, you can disable TLS verification entirely:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- GITEA_SKIP_TLS_VERIFY=true
|
||||
```
|
||||
|
||||
**WARNING**: This is insecure and should never be used in production!
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Certificate not recognized**: Ensure your certificate file has a `.crt` extension
|
||||
2. **Connection still fails**: Check that the certificate is in PEM format
|
||||
3. **Multiple certificates needed**: Add all required certificates (root and intermediate) to the certs directory
|
||||
|
||||
### Verifying Certificate Loading
|
||||
|
||||
Check the container logs to confirm certificates are loaded:
|
||||
|
||||
```bash
|
||||
docker-compose logs gitea-mirror | grep "CA certificates"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use proper CA certificates in production
|
||||
- Never disable TLS verification in production environments
|
||||
- Keep your CA certificates secure and limit access to the certs directory
|
||||
- Regularly update certificates before they expire
|
||||
|
||||
## Example Setup
|
||||
|
||||
Here's a complete example for a self-hosted Gitea with custom CA:
|
||||
|
||||
1. Copy your Gitea server's CA certificate to this directory:
|
||||
1. Create a certificates directory:
|
||||
```bash
|
||||
cp /etc/ssl/certs/my-company-ca.crt ./certs/
|
||||
mkdir -p ./certs
|
||||
```
|
||||
|
||||
2. Update `docker-compose.yml`:
|
||||
2. Copy your CA certificate(s):
|
||||
```bash
|
||||
cp /path/to/your-ca-cert.crt ./certs/
|
||||
```
|
||||
|
||||
3. Update `docker-compose.yml`:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
image: raylabs/gitea-mirror:latest
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
- ./certs:/app/certs:ro
|
||||
- ./data:/app/data
|
||||
- ./certs:/usr/local/share/ca-certificates:ro
|
||||
environment:
|
||||
- GITEA_URL=https://gitea.mycompany.local
|
||||
- GITEA_TOKEN=your-token
|
||||
# ... other configuration ...
|
||||
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
|
||||
```
|
||||
|
||||
3. Start the service:
|
||||
4. Restart the container:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
The application will now trust your custom CA when connecting to your Gitea instance.
|
||||
#### 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
|
||||
```
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -66,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}
|
||||
|
||||
@@ -28,10 +28,14 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- 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}
|
||||
@@ -49,6 +53,13 @@ services:
|
||||
- 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
|
||||
|
||||
@@ -52,15 +52,26 @@ if [ "$GITEA_SKIP_TLS_VERIFY" = "true" ]; then
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
fi
|
||||
|
||||
# Generate a secure JWT secret if one isn't provided or is using the default value
|
||||
JWT_SECRET_FILE="/app/data/.jwt_secret"
|
||||
if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then
|
||||
# Generate a secure BETTER_AUTH_SECRET if one isn't provided or is using the default value
|
||||
BETTER_AUTH_SECRET_FILE="/app/data/.better_auth_secret"
|
||||
JWT_SECRET_FILE="/app/data/.jwt_secret" # Old file for backward compatibility
|
||||
|
||||
if [ "$BETTER_AUTH_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$BETTER_AUTH_SECRET" ]; then
|
||||
# Check if we have a previously generated secret
|
||||
if [ -f "$JWT_SECRET_FILE" ]; then
|
||||
echo "Using previously generated JWT secret"
|
||||
export JWT_SECRET=$(cat "$JWT_SECRET_FILE")
|
||||
if [ -f "$BETTER_AUTH_SECRET_FILE" ]; then
|
||||
echo "Using previously generated BETTER_AUTH_SECRET"
|
||||
export BETTER_AUTH_SECRET=$(cat "$BETTER_AUTH_SECRET_FILE")
|
||||
# Check for old JWT_SECRET file for backward compatibility
|
||||
elif [ -f "$JWT_SECRET_FILE" ]; then
|
||||
echo "Migrating from old JWT_SECRET to BETTER_AUTH_SECRET"
|
||||
export BETTER_AUTH_SECRET=$(cat "$JWT_SECRET_FILE")
|
||||
# Save to new file
|
||||
echo "$BETTER_AUTH_SECRET" > "$BETTER_AUTH_SECRET_FILE"
|
||||
chmod 600 "$BETTER_AUTH_SECRET_FILE"
|
||||
# Optionally remove old file after successful migration
|
||||
rm -f "$JWT_SECRET_FILE"
|
||||
else
|
||||
echo "Generating a secure random JWT secret"
|
||||
echo "Generating a secure random BETTER_AUTH_SECRET"
|
||||
# Try to generate a secure random string using OpenSSL
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
GENERATED_SECRET=$(openssl rand -hex 32)
|
||||
@@ -69,12 +80,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
|
||||
|
||||
|
||||
@@ -232,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
|
||||
|
||||
175
docs/BETTER_AUTH_MIGRATION.md
Normal 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
@@ -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)
|
||||
355
docs/DEVELOPMENT_WORKFLOW.md
Normal 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
@@ -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
@@ -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.
|
||||
91
docs/SPONSOR_INTEGRATION.md
Normal 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
@@ -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
@@ -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
@@ -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`);
|
||||
64
drizzle/0001_polite_exodus.sql
Normal 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`);
|
||||
1290
drizzle/meta/0000_snapshot.json
Normal file
1722
drizzle/meta/0001_snapshot.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
@@ -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
23
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "2.21.0",
|
||||
"version": "3.1.1",
|
||||
"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"
|
||||
|
||||
@@ -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.");
|
||||
|
||||
110
scripts/generate-better-auth-schema.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
10
src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { LoginForm } from './LoginForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<LoginForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
10
src/components/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SignupForm } from './SignupForm';
|
||||
import Providers from '@/components/layout/Providers';
|
||||
|
||||
export function SignupPage() {
|
||||
return (
|
||||
<Providers>
|
||||
<SignupForm />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
{(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
585
src/components/config/SSOSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/components/layout/SponsorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/components/oauth/ConsentPage.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
105
src/components/sponsors/GitHubSponsors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
18
src/components/ui/textarea.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
65
src/hooks/useAuthMethods.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>;
|
||||
22
src/lib/deployment-mode.ts
Normal 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,
|
||||
};
|
||||
256
src/lib/events/realtime.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Real-time event system using EventEmitter
|
||||
* For the self-hosted version
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface RealtimeEvent {
|
||||
type: string;
|
||||
userId?: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time event bus for local instance
|
||||
*/
|
||||
export class RealtimeEventBus extends EventEmitter {
|
||||
private channels = new Map<string, Set<(event: RealtimeEvent) => void>>();
|
||||
private userChannels = new Map<string, string[]>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming events
|
||||
*/
|
||||
private handleIncomingEvent(channel: string, event: RealtimeEvent) {
|
||||
// Emit to local listeners
|
||||
this.emit(channel, event);
|
||||
|
||||
// Call channel-specific handlers
|
||||
const handlers = this.channels.get(channel);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('Error in event handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel
|
||||
*/
|
||||
async subscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
|
||||
// Add handler if provided
|
||||
if (handler) {
|
||||
if (!this.channels.has(channel)) {
|
||||
this.channels.set(channel, new Set());
|
||||
}
|
||||
this.channels.get(channel)!.add(handler);
|
||||
}
|
||||
|
||||
// Add local listener
|
||||
if (!this.listenerCount(channel)) {
|
||||
this.on(channel, (event) => this.handleIncomingEvent(channel, event));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to user-specific channels
|
||||
*/
|
||||
async subscribeUser(userId: string) {
|
||||
const channels = [
|
||||
`user:${userId}`,
|
||||
`user:${userId}:notifications`,
|
||||
`user:${userId}:updates`,
|
||||
];
|
||||
|
||||
this.userChannels.set(userId, channels);
|
||||
|
||||
for (const channel of channels) {
|
||||
await this.subscribe(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a channel
|
||||
*/
|
||||
async unsubscribe(channel: string, handler?: (event: RealtimeEvent) => void) {
|
||||
// Remove handler if provided
|
||||
if (handler) {
|
||||
this.channels.get(channel)?.delete(handler);
|
||||
|
||||
// Remove channel if no handlers left
|
||||
if (this.channels.get(channel)?.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove local listener if no handlers
|
||||
if (!this.channels.has(channel)) {
|
||||
this.removeAllListeners(channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from user channels
|
||||
*/
|
||||
async unsubscribeUser(userId: string) {
|
||||
const channels = this.userChannels.get(userId) || [];
|
||||
|
||||
for (const channel of channels) {
|
||||
await this.unsubscribe(channel);
|
||||
}
|
||||
|
||||
this.userChannels.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event
|
||||
*/
|
||||
async publish(channel: string, event: Omit<RealtimeEvent, 'timestamp'>) {
|
||||
const fullEvent: RealtimeEvent = {
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Emit locally
|
||||
this.handleIncomingEvent(channel, fullEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast to all users
|
||||
*/
|
||||
async broadcast(event: Omit<RealtimeEvent, 'timestamp'>) {
|
||||
await this.publish('broadcast', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to specific user
|
||||
*/
|
||||
async sendToUser(userId: string, event: Omit<RealtimeEvent, 'timestamp' | 'userId'>) {
|
||||
await this.publish(`user:${userId}`, {
|
||||
...event,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send activity update
|
||||
*/
|
||||
async sendActivity(activity: {
|
||||
userId: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
resourceId: string;
|
||||
details?: any;
|
||||
}) {
|
||||
const event = {
|
||||
type: 'activity',
|
||||
data: activity,
|
||||
};
|
||||
|
||||
// Send to user
|
||||
await this.sendToUser(activity.userId, event);
|
||||
|
||||
// Also publish to activity channel
|
||||
await this.publish('activity', {
|
||||
...event,
|
||||
userId: activity.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
channels: this.channels.size,
|
||||
listeners: Array.from(this.channels.values()).reduce(
|
||||
(sum, handlers) => sum + handlers.size,
|
||||
0
|
||||
),
|
||||
userChannels: this.userChannels.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global event bus instance
|
||||
export const eventBus = new RealtimeEventBus();
|
||||
|
||||
/**
|
||||
* React hook for subscribing to events
|
||||
*/
|
||||
export function useRealtimeEvents(
|
||||
channel: string,
|
||||
handler: (event: RealtimeEvent) => void,
|
||||
deps: any[] = []
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const { useEffect } = require('react');
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.subscribe(channel, handler);
|
||||
|
||||
return () => {
|
||||
eventBus.unsubscribe(channel, handler);
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-sent events endpoint handler
|
||||
*/
|
||||
export async function createSSEHandler(userId: string) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send initial connection event
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
|
||||
);
|
||||
|
||||
// Subscribe to user channels
|
||||
await eventBus.subscribeUser(userId);
|
||||
|
||||
// Create event handler
|
||||
const handleEvent = (event: RealtimeEvent) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
);
|
||||
};
|
||||
|
||||
// Subscribe to channels
|
||||
eventBus.on(`user:${userId}`, handleEvent);
|
||||
|
||||
// Keep connection alive with heartbeat
|
||||
const heartbeat = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
return () => {
|
||||
clearInterval(heartbeat);
|
||||
eventBus.off(`user:${userId}`, handleEvent);
|
||||
eventBus.unsubscribeUser(userId);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||