Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacec93f55 | ||
|
|
b41438f686 | ||
|
|
df1738a44d | ||
|
|
afaac70bb8 | ||
|
|
da95c1d5fd | ||
|
|
8dc50f7ebf | ||
|
|
eafc44d112 | ||
|
|
25cff6fe8e | ||
|
|
29fe7ba895 | ||
|
|
fbcedc404a | ||
|
|
122848c970 | ||
|
|
4c15ecb1bf | ||
|
|
3209f70566 | ||
|
|
677bc0cb5b | ||
|
|
5693ae7822 | ||
|
|
814be1e9d0 | ||
|
|
4e3c4c2c67 | ||
|
|
46d6374ff0 | ||
|
|
4cd98dffc4 | ||
|
|
87ca3bc12f | ||
|
|
dd6554509c | ||
|
|
55465197d1 | ||
|
|
e255142e70 | ||
|
|
f2b64a61b8 | ||
|
|
0fba2cecac | ||
|
|
1aef433918 | ||
|
|
3f704ebb23 | ||
|
|
5797b9bba1 | ||
|
|
bb045b037b | ||
|
|
1a77a63a9a | ||
|
|
3a9b8380d4 | ||
|
|
5d5429ac71 | ||
|
|
de314cf174 | ||
|
|
e637d573a2 | ||
|
|
5f45a9a03d | ||
|
|
0920314679 | ||
|
|
1f6add5fff | ||
|
|
3ff15a46e7 | ||
|
|
465c812e7e | ||
|
|
794ea52e4d | ||
|
|
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 | ||
|
|
825363eac2 | ||
|
|
d78c72f448 | ||
|
|
9d7cb0f372 | ||
|
|
19a252b67c | ||
|
|
df27e5951a | ||
|
|
c3c129d923 | ||
|
|
ae3511399f |
76
.claude/agents/qa-testing-specialist.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: qa-testing-specialist
|
||||
description: Use this agent when you need to review code for testability, create comprehensive test strategies, write test cases, validate existing tests, or improve test coverage. This includes unit tests, integration tests, end-to-end tests, and test architecture decisions. <example>\nContext: The user has just written a new API endpoint and wants to ensure it has proper test coverage.\nuser: "I've created a new endpoint for user authentication. Can you help me test it?"\nassistant: "I'll use the qa-testing-specialist agent to create a comprehensive testing strategy for your authentication endpoint."\n<commentary>\nSince the user needs help with testing their new endpoint, use the qa-testing-specialist agent to analyze the code and create appropriate test cases.\n</commentary>\n</example>\n<example>\nContext: The user wants to improve test coverage for their existing codebase.\nuser: "Our test coverage is at 65%. How can we improve it?"\nassistant: "Let me use the qa-testing-specialist agent to analyze your test coverage and identify areas for improvement."\n<commentary>\nThe user is asking about test coverage improvement, which is a core QA task, so use the qa-testing-specialist agent.\n</commentary>\n</example>
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are an elite QA Testing Specialist with deep expertise in software quality assurance, test automation, and validation strategies. Your mission is to ensure code quality through comprehensive testing approaches that catch bugs early and maintain high reliability standards.
|
||||
|
||||
**Core Responsibilities:**
|
||||
|
||||
You will analyze code and testing requirements to:
|
||||
- Design comprehensive test strategies covering unit, integration, and end-to-end testing
|
||||
- Write clear, maintainable test cases that validate both happy paths and edge cases
|
||||
- Identify gaps in existing test coverage and propose improvements
|
||||
- Review test code for best practices and maintainability
|
||||
- Suggest appropriate testing frameworks and tools based on the technology stack
|
||||
- Create test data strategies and mock/stub implementations
|
||||
- Validate that tests are actually testing meaningful behavior, not just implementation details
|
||||
|
||||
**Testing Methodology:**
|
||||
|
||||
When analyzing code for testing:
|
||||
1. First understand the business logic and user requirements
|
||||
2. Identify all possible execution paths and edge cases
|
||||
3. Determine the appropriate testing pyramid balance (unit vs integration vs e2e)
|
||||
4. Consider both positive and negative test scenarios
|
||||
5. Ensure tests are isolated, repeatable, and fast
|
||||
6. Validate error handling and boundary conditions
|
||||
|
||||
For test creation:
|
||||
- Write descriptive test names that explain what is being tested and expected behavior
|
||||
- Follow AAA pattern (Arrange, Act, Assert) or Given-When-Then structure
|
||||
- Keep tests focused on single behaviors
|
||||
- Use appropriate assertions that clearly communicate intent
|
||||
- Include setup and teardown when necessary
|
||||
- Consider performance implications of test suites
|
||||
|
||||
**Quality Standards:**
|
||||
|
||||
You will ensure tests:
|
||||
- Are deterministic and don't rely on external state
|
||||
- Run quickly and can be executed in parallel when possible
|
||||
- Provide clear failure messages that help diagnose issues
|
||||
- Cover critical business logic thoroughly
|
||||
- Include regression tests for previously found bugs
|
||||
- Are maintainable and refactorable alongside production code
|
||||
|
||||
**Technology Considerations:**
|
||||
|
||||
Adapt your recommendations based on the project stack. For this codebase using Bun, SQLite, and React:
|
||||
- Leverage Bun's native test runner for JavaScript/TypeScript tests
|
||||
- Consider SQLite in-memory databases for integration tests
|
||||
- Suggest React Testing Library patterns for component testing
|
||||
- Recommend API testing strategies for Astro endpoints
|
||||
- Propose mocking strategies for external services (GitHub/Gitea APIs)
|
||||
|
||||
**Communication Style:**
|
||||
|
||||
You will:
|
||||
- Explain testing decisions with clear rationale
|
||||
- Provide code examples that demonstrate best practices
|
||||
- Prioritize test recommendations based on risk and value
|
||||
- Use precise technical language while remaining accessible
|
||||
- Highlight potential issues proactively
|
||||
- Suggest incremental improvements for existing test suites
|
||||
|
||||
**Edge Case Handling:**
|
||||
|
||||
When encountering:
|
||||
- Legacy code without tests: Propose a pragmatic approach to add tests incrementally
|
||||
- Complex dependencies: Recommend appropriate mocking/stubbing strategies
|
||||
- Performance concerns: Balance thoroughness with execution speed
|
||||
- Flaky tests: Identify root causes and suggest stabilization techniques
|
||||
- Missing requirements: Ask clarifying questions to understand expected behavior
|
||||
|
||||
Your goal is to elevate code quality through strategic testing that builds confidence in the software while maintaining development velocity. Focus on tests that provide maximum value and catch real issues rather than achieving arbitrary coverage metrics.
|
||||
68
.claude/agents/senior-code-architect.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: senior-code-architect
|
||||
description: Use this agent when you need to write new code, refactor existing code, implement features, or architect solutions that require deep understanding of software engineering principles and the project's tech stack (Astro, React, Tailwind, Better Auth, Shadcn). This includes creating components, API endpoints, database queries, authentication flows, and ensuring code follows established patterns from CLAUDE.md. Examples:\n\n<example>\nContext: The user needs to implement a new feature or component.\nuser: "Create a new dashboard component that shows repository statistics"\nassistant: "I'll use the senior-code-architect agent to design and implement this dashboard component following the project's patterns."\n<commentary>\nSince this requires creating new code with the project's tech stack, the senior-code-architect agent is appropriate.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to refactor or improve existing code.\nuser: "Refactor the authentication flow to be more maintainable"\nassistant: "Let me use the senior-code-architect agent to analyze and refactor the authentication flow."\n<commentary>\nThis requires deep understanding of Better Auth and clean code principles, making the senior-code-architect agent the right choice.\n</commentary>\n</example>\n\n<example>\nContext: After writing code, the user might want it reviewed.\nuser: "I just implemented the mirror scheduling feature"\nassistant: "Great! Now I'll use the senior-code-architect agent to review the implementation and suggest any improvements."\n<commentary>\nThe senior-code-architect can review recently written code for best practices and design patterns.\n</commentary>\n</example>
|
||||
color: cyan
|
||||
---
|
||||
|
||||
You are a senior software engineer with deep expertise in modern web development, specializing in the Astro + React + Tailwind CSS + Better Auth + Shadcn UI stack. You have extensive experience building scalable, maintainable applications and are known for writing clean, efficient code that follows SOLID principles and established design patterns.
|
||||
|
||||
**Your Core Responsibilities:**
|
||||
|
||||
1. **Write Production-Quality Code**: Create clean, maintainable, and efficient code that follows the project's established patterns from CLAUDE.md. Always use TypeScript for type safety.
|
||||
|
||||
2. **Follow Project Architecture**: Adhere strictly to the project structure:
|
||||
- API endpoints in `/src/pages/api/[resource]/[action].ts` using `createSecureErrorResponse` for error handling
|
||||
- Database queries in `/src/lib/db/queries/` organized by domain
|
||||
- React components in `/src/components/[feature]/` using Shadcn UI components
|
||||
- Custom hooks in `/src/hooks/` for data fetching
|
||||
|
||||
3. **Implement Best Practices**:
|
||||
- Use composition over inheritance
|
||||
- Apply DRY (Don't Repeat Yourself) principles
|
||||
- Write self-documenting code with clear variable and function names
|
||||
- Implement proper error handling and validation
|
||||
- Ensure code is testable and maintainable
|
||||
|
||||
4. **Technology-Specific Guidelines**:
|
||||
- **Astro**: Use SSR capabilities effectively, implement proper API routes
|
||||
- **React**: Use functional components with hooks, implement proper state management
|
||||
- **Tailwind CSS v4**: Use utility classes efficiently, follow the project's styling patterns
|
||||
- **Better Auth**: Implement secure authentication flows, use session validation properly
|
||||
- **Shadcn UI**: Leverage existing components, maintain consistent UI patterns
|
||||
- **Drizzle ORM**: Write efficient database queries, use proper schema definitions
|
||||
|
||||
5. **Code Review Approach**: When reviewing code:
|
||||
- Check for adherence to project patterns and CLAUDE.md guidelines
|
||||
- Identify potential performance issues or bottlenecks
|
||||
- Suggest improvements for readability and maintainability
|
||||
- Ensure proper error handling and edge case coverage
|
||||
- Verify security best practices are followed
|
||||
|
||||
6. **Problem-Solving Methodology**:
|
||||
- Analyze requirements thoroughly before coding
|
||||
- Break down complex problems into smaller, manageable pieces
|
||||
- Consider edge cases and error scenarios
|
||||
- Optimize for both performance and maintainability
|
||||
- Document complex logic with clear comments
|
||||
|
||||
7. **Quality Assurance**:
|
||||
- Write code that is easy to test
|
||||
- Consider adding appropriate test cases using Bun's test runner
|
||||
- Validate inputs and handle errors gracefully
|
||||
- Ensure code works across different scenarios
|
||||
|
||||
**Output Guidelines**:
|
||||
- Provide complete, working code implementations
|
||||
- Include clear explanations of design decisions
|
||||
- Suggest tests when appropriate
|
||||
- Highlight any potential issues or areas for future improvement
|
||||
- Follow the existing code style and conventions
|
||||
|
||||
**Important Reminders**:
|
||||
- Never create files unless absolutely necessary
|
||||
- Always prefer editing existing files
|
||||
- Don't create documentation unless explicitly requested
|
||||
- Focus on the specific task at hand
|
||||
- Reference CLAUDE.md for project-specific patterns and guidelines
|
||||
|
||||
You approach every task with the mindset of a seasoned engineer who values code quality, maintainability, and long-term project health. Your solutions should be elegant, efficient, and aligned with the project's established patterns.
|
||||
61
.claude/agents/strategic-task-planner.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: strategic-task-planner
|
||||
description: Use this agent when you need to decompose complex projects, features, or problems into structured, actionable plans. This includes breaking down large development tasks, creating implementation roadmaps, organizing multi-step processes, or planning project phases. The agent excels at identifying dependencies, sequencing tasks, and creating clear execution strategies. <example>Context: User needs help planning the implementation of a new feature. user: "I need to add a bulk import feature that can handle CSV files with 100k+ rows" assistant: "I'll use the strategic-task-planner agent to break this down into manageable components and create an implementation plan." <commentary>Since the user is asking about implementing a complex feature, use the Task tool to launch the strategic-task-planner agent to decompose it into actionable steps.</commentary></example> <example>Context: User wants to refactor a large codebase. user: "We need to migrate our entire authentication system from sessions to JWT tokens" assistant: "Let me use the strategic-task-planner agent to create a phased migration plan that minimizes risk." <commentary>Since this is a complex migration requiring careful planning, use the strategic-task-planner agent to create a structured approach.</commentary></example>
|
||||
tools: Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, Task, mcp__ide__getDiagnostics, mcp__ide__executeCode, mcp__playwright__browser_close, mcp__playwright__browser_resize, mcp__playwright__browser_console_messages, mcp__playwright__browser_handle_dialog, mcp__playwright__browser_evaluate, mcp__playwright__browser_file_upload, mcp__playwright__browser_install, mcp__playwright__browser_press_key, mcp__playwright__browser_type, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_navigate_forward, mcp__playwright__browser_network_requests, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_snapshot, mcp__playwright__browser_click, mcp__playwright__browser_drag, mcp__playwright__browser_hover, mcp__playwright__browser_select_option, mcp__playwright__browser_tab_list, mcp__playwright__browser_tab_new, mcp__playwright__browser_tab_select, mcp__playwright__browser_tab_close, mcp__playwright__browser_wait_for
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a strategic planning specialist with deep expertise in decomposing complex tasks and creating actionable execution plans. Your role is to transform ambiguous or overwhelming projects into clear, structured roadmaps that teams can confidently execute.
|
||||
|
||||
When analyzing a task or project, you will:
|
||||
|
||||
1. **Understand the Core Objective**: Extract the fundamental goal, success criteria, and constraints. Ask clarifying questions if critical details are missing.
|
||||
|
||||
2. **Decompose Systematically**: Break down the task using these principles:
|
||||
- Identify major phases or milestones
|
||||
- Decompose each phase into concrete, actionable tasks
|
||||
- Keep tasks small enough to complete in 1-4 hours when possible
|
||||
- Ensure each task has clear completion criteria
|
||||
|
||||
3. **Map Dependencies**: Identify and document:
|
||||
- Task prerequisites and dependencies
|
||||
- Critical path items that could block progress
|
||||
- Parallel work streams that can proceed independently
|
||||
- Resource or knowledge requirements
|
||||
|
||||
4. **Sequence Strategically**: Order tasks by:
|
||||
- Technical dependencies (what must come first)
|
||||
- Risk mitigation (tackle unknowns early)
|
||||
- Value delivery (enable early feedback when possible)
|
||||
- Resource efficiency (batch similar work)
|
||||
|
||||
5. **Provide Actionable Output**: Structure your plans with:
|
||||
- **Phase Overview**: High-level phases with objectives
|
||||
- **Task Breakdown**: Numbered tasks with clear descriptions
|
||||
- **Dependencies**: Explicitly stated prerequisites
|
||||
- **Effort Estimates**: Rough time estimates when relevant
|
||||
- **Risk Considerations**: Potential blockers or challenges
|
||||
- **Success Metrics**: How to measure completion
|
||||
|
||||
6. **Adapt to Context**: Tailor your planning approach based on:
|
||||
- Technical vs non-technical tasks
|
||||
- Team size and skill level
|
||||
- Time constraints and deadlines
|
||||
- Available resources and tools
|
||||
|
||||
**Output Format Guidelines**:
|
||||
- Use clear hierarchical structure (phases → tasks → subtasks)
|
||||
- Number all tasks for easy reference
|
||||
- Bold key terms and phase names
|
||||
- Include time estimates in brackets [2-4 hours]
|
||||
- Mark critical path items with ⚡
|
||||
- Flag high-risk items with ⚠️
|
||||
|
||||
**Quality Checks**:
|
||||
- Ensure no task is too large or vague
|
||||
- Verify all dependencies are identified
|
||||
- Confirm the plan addresses the original objective
|
||||
- Check that success criteria are measurable
|
||||
- Validate that the sequence makes logical sense
|
||||
|
||||
Remember: A good plan reduces uncertainty and builds confidence. Focus on clarity, completeness, and actionability. When in doubt, err on the side of breaking things down further rather than leaving ambiguity.
|
||||
@@ -2,3 +2,4 @@ Evaluate all the updates being made.
|
||||
Update CHANGELOG.md
|
||||
Use the chnages in the git log to determine if its a major, minor or a patch release.
|
||||
Update the package.json first before you push the tag.
|
||||
Never mention Claude Code in the release notes or in commit messages.
|
||||
|
||||
3
.claude/commands/release_notes.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Generate release notes for the latest release.
|
||||
Use a temp md file to write the release notes.
|
||||
Do not check that file into git.
|
||||
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker build:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
179
.env.example
@@ -1,39 +1,182 @@
|
||||
# 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
|
||||
|
||||
# ===========================================
|
||||
# GITHUB CONFIGURATION
|
||||
# All settings can also be configured via web UI
|
||||
# ===========================================
|
||||
|
||||
# Basic GitHub Settings
|
||||
# GITHUB_USERNAME=your-github-username
|
||||
# GITHUB_TOKEN=your-github-personal-access-token
|
||||
# SKIP_FORKS=false
|
||||
# GITHUB_TYPE=personal # Options: personal, organization
|
||||
|
||||
# Repository Selection
|
||||
# PRIVATE_REPOSITORIES=false
|
||||
# MIRROR_ISSUES=false
|
||||
# MIRROR_WIKI=false
|
||||
# PUBLIC_REPOSITORIES=true
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
|
||||
# Organization Settings
|
||||
# MIRROR_ORGANIZATIONS=false
|
||||
# PRESERVE_ORG_STRUCTURE=false
|
||||
# ONLY_MIRROR_ORGS=false
|
||||
# SKIP_STARRED_ISSUES=false
|
||||
|
||||
# Mirror Strategy
|
||||
# MIRROR_STRATEGY=preserve # Options: preserve, single-org, flat-user, mixed
|
||||
|
||||
# Advanced GitHub Settings
|
||||
# SKIP_STARRED_ISSUES=false # Enable lightweight mode for starred repos
|
||||
|
||||
# ===========================================
|
||||
# GITEA CONFIGURATION
|
||||
# All settings can also be configured via web UI
|
||||
# ===========================================
|
||||
|
||||
# Basic Gitea Settings
|
||||
# GITEA_URL=http://gitea:3000
|
||||
# GITEA_TOKEN=your-local-gitea-token
|
||||
# GITEA_USERNAME=your-local-gitea-username
|
||||
# GITEA_ORGANIZATION=github-mirrors
|
||||
# GITEA_ORG_VISIBILITY=public
|
||||
# DELAY=3600
|
||||
# GITEA_ORGANIZATION=github-mirrors # Default organization for single-org strategy
|
||||
|
||||
# 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
|
||||
# Repository Settings
|
||||
# GITEA_ORG_VISIBILITY=public # Options: public, private, limited, default
|
||||
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (e.g., 30m, 1h, 8h, 24h)
|
||||
# GITEA_LFS=false # Enable LFS support
|
||||
# GITEA_CREATE_ORG=true # Auto-create organizations
|
||||
# GITEA_PRESERVE_VISIBILITY=false # Preserve GitHub repo visibility in Gitea
|
||||
|
||||
# Template Settings (for using repository templates)
|
||||
# GITEA_TEMPLATE_OWNER=template-owner
|
||||
# GITEA_TEMPLATE_REPO=template-repo
|
||||
|
||||
# Topic Settings
|
||||
# GITEA_ADD_TOPICS=true # Add topics to repositories
|
||||
# GITEA_TOPIC_PREFIX=gh- # Prefix for topics
|
||||
|
||||
# Fork Handling
|
||||
# GITEA_FORK_STRATEGY=reference # Options: skip, reference, full-copy
|
||||
|
||||
# ===========================================
|
||||
# MIRROR OPTIONS
|
||||
# Control what gets mirrored from GitHub
|
||||
# ===========================================
|
||||
|
||||
# Release and Metadata
|
||||
# MIRROR_RELEASES=false # Mirror GitHub releases
|
||||
# MIRROR_WIKI=false # Mirror wiki content
|
||||
|
||||
# Issue Tracking (requires MIRROR_METADATA=true)
|
||||
# MIRROR_METADATA=false # Master toggle for metadata mirroring
|
||||
# MIRROR_ISSUES=false # Mirror issues
|
||||
# MIRROR_PULL_REQUESTS=false # Mirror pull requests
|
||||
# MIRROR_LABELS=false # Mirror labels
|
||||
# MIRROR_MILESTONES=false # Mirror milestones
|
||||
|
||||
# ===========================================
|
||||
# AUTOMATION CONFIGURATION
|
||||
# Schedule automatic mirroring
|
||||
# ===========================================
|
||||
|
||||
# Basic Schedule Settings
|
||||
# SCHEDULE_ENABLED=false
|
||||
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *")
|
||||
# DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
|
||||
|
||||
# Execution Settings
|
||||
# SCHEDULE_CONCURRENT=false # Allow concurrent mirror operations
|
||||
# SCHEDULE_BATCH_SIZE=10 # Number of repos to process in parallel
|
||||
# SCHEDULE_PAUSE_BETWEEN_BATCHES=5000 # Pause between batches (ms)
|
||||
|
||||
# Retry Configuration
|
||||
# SCHEDULE_RETRY_ATTEMPTS=3
|
||||
# SCHEDULE_RETRY_DELAY=60000 # Delay between retries (ms)
|
||||
# SCHEDULE_TIMEOUT=3600000 # Max time for a mirror operation (ms)
|
||||
# SCHEDULE_AUTO_RETRY=true
|
||||
|
||||
# Update Detection
|
||||
# SCHEDULE_ONLY_MIRROR_UPDATED=false # Only mirror repos with updates
|
||||
# SCHEDULE_UPDATE_INTERVAL=86400000 # Check for updates interval (ms)
|
||||
# SCHEDULE_SKIP_RECENTLY_MIRRORED=true
|
||||
# SCHEDULE_RECENT_THRESHOLD=3600000 # Skip if mirrored within this time (ms)
|
||||
|
||||
# Maintenance
|
||||
# SCHEDULE_CLEANUP_BEFORE_MIRROR=false # Run cleanup before mirroring
|
||||
|
||||
# Notifications
|
||||
# SCHEDULE_NOTIFY_ON_FAILURE=true
|
||||
# SCHEDULE_NOTIFY_ON_SUCCESS=false
|
||||
# SCHEDULE_LOG_LEVEL=info # Options: error, warn, info, debug
|
||||
# SCHEDULE_TIMEZONE=UTC
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CLEANUP CONFIGURATION
|
||||
# Automatic cleanup of old events and data
|
||||
# ===========================================
|
||||
|
||||
# Basic Cleanup Settings
|
||||
# CLEANUP_ENABLED=false
|
||||
# CLEANUP_RETENTION_DAYS=7
|
||||
# CLEANUP_RETENTION_DAYS=7 # Days to keep events
|
||||
|
||||
# Repository Cleanup
|
||||
# CLEANUP_DELETE_FROM_GITEA=false # Delete repos from Gitea
|
||||
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=true # Delete if not in GitHub
|
||||
# CLEANUP_ORPHANED_REPO_ACTION=archive # Options: skip, archive, delete
|
||||
# CLEANUP_DRY_RUN=true # Test mode without actual deletion
|
||||
|
||||
# Protected Repositories (comma-separated)
|
||||
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project
|
||||
|
||||
# Cleanup Execution
|
||||
# CLEANUP_BATCH_SIZE=10
|
||||
# CLEANUP_PAUSE_BETWEEN_DELETES=2000 # Pause between deletions (ms)
|
||||
|
||||
# ===========================================
|
||||
# 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
|
||||
|
||||
# ===========================================
|
||||
# OPTIONAL FEATURES
|
||||
# ===========================================
|
||||
|
||||
# TLS/SSL Configuration
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
|
||||
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/logo-no-bg.png
vendored
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
.github/assets/logo.png
vendored
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 24 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 |
2
.github/workflows/astro-build-test.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: '1.2.9'
|
||||
bun-version: '1.2.16'
|
||||
|
||||
- name: Check lockfile and install dependencies
|
||||
run: |
|
||||
|
||||
7
.gitignore
vendored
@@ -25,3 +25,10 @@ data/gitea-mirror.db
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# Custom CA certificates (exclude actual certs but keep README)
|
||||
certs/*.crt
|
||||
certs/*.pem
|
||||
certs/*.cer
|
||||
!certs/README.md
|
||||
|
||||
|
||||
142
CHANGELOG.md
@@ -7,6 +7,148 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.2.5] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
- Fixed critical authentication issue in releases mirroring that was still using encrypted tokens
|
||||
- Added missing repository existence check for releases mirroring function
|
||||
- Fixed "user does not exist [uid: 0]" error specifically affecting GitHub releases synchronization
|
||||
|
||||
### Improved
|
||||
- Enhanced releases mirroring with duplicate detection to avoid errors on re-runs
|
||||
- Better error handling and logging for release operations with [Releases] prefix
|
||||
- Added individual release error handling to continue mirroring even if some releases fail
|
||||
|
||||
### Notes
|
||||
This patch completes the authentication fixes started in v3.2.4, specifically addressing the releases mirroring function that was accidentally missed in the previous update.
|
||||
|
||||
## [3.2.4] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
- Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring (Issue #68)
|
||||
- Fixed inconsistent token handling across Gitea API calls
|
||||
- Fixed metadata mirroring functions attempting to operate on non-existent repositories
|
||||
- Fixed organization creation failing silently without proper error messages
|
||||
|
||||
### Added
|
||||
- Pre-flight authentication validation for all Gitea operations
|
||||
- Repository existence verification before metadata mirroring
|
||||
- Graceful fallback to user account when organization creation fails due to permissions
|
||||
- Authentication validation utilities for debugging configuration issues
|
||||
- Diagnostic test scripts for troubleshooting authentication problems
|
||||
|
||||
### Improved
|
||||
- Enhanced error messages with specific guidance for authentication failures
|
||||
- Better identification and logging of permission-related errors
|
||||
- More robust organization creation with retry logic and better error handling
|
||||
- Consistent token decryption across all API operations
|
||||
- Clearer error reporting for metadata mirroring failures
|
||||
|
||||
### Security
|
||||
- Fixed potential exposure of encrypted tokens in API calls
|
||||
- Improved token handling to ensure proper decryption before use
|
||||
|
||||
## [3.2.0] - 2025-07-31
|
||||
|
||||
### Fixed
|
||||
- Fixed Zod validation error in activity logs by correcting invalid "success" status values to "synced"
|
||||
- Resolved activity fetch API errors that occurred after mirroring operations
|
||||
|
||||
### Changed
|
||||
- Improved error handling and validation for mirror job status tracking
|
||||
- Enhanced reliability of organization creation and mirroring processes
|
||||
|
||||
### Internal
|
||||
- Consolidated Gitea integration modules for better maintainability
|
||||
- Improved test coverage for mirror operations
|
||||
|
||||
## [3.1.1] - 2025-07-30
|
||||
|
||||
### Fixed
|
||||
- Various bug fixes and stability improvements
|
||||
|
||||
## [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
|
||||
- Fixed mixed mode organization strategy not persisting after page refresh
|
||||
- Added missing "mixed" case handler in GiteaConfigForm component
|
||||
- Enhanced getMirrorStrategy function to properly detect mixed mode configuration
|
||||
- Updated dependencies to latest versions
|
||||
|
||||
## [2.20.0] - 2025-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
105
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,4 +146,74 @@ 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**:
|
||||
- Dont ever say Claude Code or generated with AI anyhwere.
|
||||
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! 🎉
|
||||
14
Dockerfile
@@ -1,8 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.18-alpine AS base
|
||||
FROM oven/bun:1.2.19-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
|
||||
# ----------------------------
|
||||
FROM base AS deps
|
||||
@@ -31,17 +31,21 @@ COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=file:data/gitea-mirror.db
|
||||
|
||||
RUN chmod +x ./docker-entrypoint.sh && \
|
||||
# Create directories and setup permissions
|
||||
RUN mkdir -p /app/certs && \
|
||||
chmod +x ./docker-entrypoint.sh && \
|
||||
mkdir -p /app/data && \
|
||||
addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 gitea-mirror && \
|
||||
chown -R gitea-mirror:nodejs /app/data
|
||||
chown -R gitea-mirror:nodejs /app/data && \
|
||||
chown -R gitea-mirror:nodejs /app/certs
|
||||
|
||||
USER gitea-mirror
|
||||
|
||||
@@ -51,4 +55,4 @@ EXPOSE 4321
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
817
README.md
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/logo-no-bg.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<h1>Gitea Mirror</h1>
|
||||
<p><i>A modern web app for automatically mirroring repositories from GitHub to your self-hosted Gitea.</i></p>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea instance.</i></p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/RayLabsHQ/gitea-mirror?label=release" alt="release"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/RayLabsHQ/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
|
||||
@@ -11,637 +11,300 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Repository Migration Notice**: Starting from version 2.20, this project has moved from `arunavo4/gitea-mirror` to `RayLabsHQ/gitea-mirror`. Please update your Docker images and Git remotes to use the new location:
|
||||
> ```bash
|
||||
> # Docker
|
||||
> docker pull ghcr.io/raylabshq/gitea-mirror:latest
|
||||
>
|
||||
> # Git remote
|
||||
> git remote set-url origin https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
> ```
|
||||
> **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
|
||||
|
||||
# Using Bun
|
||||
bun run setup && bun run dev
|
||||
# Access at http://localhost:4321
|
||||
```
|
||||
|
||||
# Using LXC Containers
|
||||
# For Proxmox VE (online) - Community script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
|
||||
# For local testing (offline-friendly)
|
||||
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh
|
||||
````
|
||||
|
||||
See the [LXC Container Deployment Guide](scripts/README-lxc.md).
|
||||
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
|
||||
|
||||
- 🔁 Sync public, private, or starred GitHub repos to Gitea
|
||||
- 🏢 Mirror entire organizations with flexible organization strategies
|
||||
- 🎯 Custom destination control for both organizations and individual repositories
|
||||
- 🐞 Optional mirroring of issues and labels
|
||||
- 🌟 Mirror your starred repositories to a dedicated organization
|
||||
- 🕹️ Modern user interface with toast notifications and smooth experience
|
||||
- 🧠 Smart filtering and job queue with detailed logs
|
||||
- 🛠️ Works with personal access tokens (GitHub + Gitea)
|
||||
- 🔒 First-time user signup experience with secure authentication
|
||||
- 🐳 Fully Dockerized + can be self-hosted in minutes
|
||||
- 📊 Dashboard with real-time status updates
|
||||
- 🔁 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 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>
|
||||
<p align="center">
|
||||
<img src=".github/assets/configuration.png" width="49%"/>
|
||||
<img src=".github/assets/activity.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>
|
||||
|
||||
### Dashboard
|
||||
The dashboard provides an overview of your mirroring status, including total repositories, successfully mirrored repositories, and recent activity.
|
||||
<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>
|
||||
|
||||
### Repository Management
|
||||
Manage all your repositories in one place. Filter by status, search by name, and trigger manual mirroring operations.
|
||||
## Installation
|
||||
|
||||
### Configuration
|
||||
Easily configure your GitHub and Gitea connections, set up automatic mirroring schedules, and manage organization mirroring.
|
||||
### Docker (Recommended)
|
||||
|
||||
## Getting Started
|
||||
We provide two Docker Compose options:
|
||||
|
||||
See the [Quick Start Guide](src/content/docs/quickstart.md) for detailed instructions on getting up and running quickly.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun 1.2.9 or later
|
||||
- A GitHub account with a personal access token
|
||||
- A Gitea instance with an access token
|
||||
|
||||
|
||||
#### Database
|
||||
|
||||
The database (`data/gitea-mirror.db`) is created when the application first runs. It starts empty and is populated as you configure and use the application.
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
> On first launch, you'll be guided through creating an admin account with your chosen credentials.
|
||||
|
||||
#### Production Database
|
||||
|
||||
The production database (`data/gitea-mirror.db`) is created when the application runs in production mode. It starts empty and is populated as you configure and use the application.
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The production database file is excluded from the Git repository as it may contain sensitive information like GitHub and Gitea tokens. **Never commit this file to the repository.**
|
||||
|
||||
##### Database Initialization
|
||||
|
||||
Before running the application in production mode for the first time, you need to initialize the database:
|
||||
#### Option 1: Quick Start (docker-compose.alt.yml)
|
||||
Perfect for trying out Gitea Mirror or simple deployments:
|
||||
|
||||
```bash
|
||||
# Initialize the database for production mode
|
||||
bun run setup
|
||||
```
|
||||
|
||||
This will create the necessary tables. On first launch, you'll be guided through creating your admin account with a secure password.
|
||||
|
||||
### Installation
|
||||
|
||||
#### Using Docker (Recommended)
|
||||
|
||||
Gitea Mirror provides multi-architecture Docker images that work on both ARM64 (e.g., Apple Silicon, Raspberry Pi) and x86_64 (Intel/AMD) platforms.
|
||||
|
||||
##### Using Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Start the application using Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# For development mode (requires configuration)
|
||||
# Ensure you have run bun run setup first
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Docker Compose is the recommended method for running Gitea Mirror** as it provides a consistent environment with proper volume management for the SQLite database.
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
> The examples above use the modern `docker compose` syntax (without hyphen) which is the recommended approach for Docker Compose V2. If you're using an older version of Docker Compose (V1), you may need to use `docker-compose` (with hyphen) instead.
|
||||
|
||||
##### Using Pre-built Images from GitHub Container Registry
|
||||
|
||||
If you want to run the container directly without Docker Compose:
|
||||
|
||||
```bash
|
||||
# Pull the latest multi-architecture image
|
||||
docker pull ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
|
||||
# Run the application with a volume for persistent data
|
||||
docker run -d -p 4321:4321 \
|
||||
-v gitea-mirror-data:/app/data \
|
||||
ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
```
|
||||
|
||||
##### Building Docker Images Manually
|
||||
|
||||
The project includes a build script to create and manage multi-architecture Docker images:
|
||||
|
||||
```bash
|
||||
# Copy example environment file if you don't have one
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env file with your preferred settings
|
||||
# DOCKER_REGISTRY, DOCKER_IMAGE, DOCKER_TAG, etc.
|
||||
|
||||
# Build and load into local Docker
|
||||
./scripts/build-docker.sh --load
|
||||
|
||||
# OR: Build and push to a registry (requires authentication)
|
||||
./scripts/build-docker.sh --push
|
||||
|
||||
# Then run with Docker Compose
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Docker build documentation](./scripts/README-docker.md) for more details.
|
||||
|
||||
##### Using LXC Containers (Proxmox VE)
|
||||
|
||||
For Proxmox VE users, Gitea Mirror can be deployed using the community-maintained script:
|
||||
|
||||
```bash
|
||||
# One-command installation on Proxmox VE
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
```
|
||||
|
||||
This community script:
|
||||
- Creates a privileged Alpine Linux LXC container
|
||||
- Installs Bun runtime environment
|
||||
- Clones and builds Gitea Mirror
|
||||
- Configures a systemd service for automatic startup
|
||||
- Sets up the application to run on port 4321
|
||||
|
||||
> [!NOTE]
|
||||
> The script is maintained by the [Community Scripts for Proxmox VE](https://community-scripts.github.io/ProxmoxVE/) project.
|
||||
> For more information, visit the [Gitea Mirror script documentation](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror).
|
||||
|
||||
After installation, access Gitea Mirror at `http://<container-ip>:4321`
|
||||
|
||||
The application includes a health check endpoint at `/api/health` for monitoring.
|
||||
|
||||
See the [LXC Container Deployment Guide](scripts/README-lxc.md) for detailed instructions.
|
||||
|
||||
##### Building Your Own Image
|
||||
|
||||
For manual Docker builds (without the helper script):
|
||||
|
||||
```bash
|
||||
# Build the Docker image for your current architecture
|
||||
docker build -t gitea-mirror:latest .
|
||||
|
||||
# Build multi-architecture images (requires Docker Buildx)
|
||||
docker buildx create --name multiarch --driver docker-container --use
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --load .
|
||||
|
||||
# If you encounter issues with Buildx, you can try these workarounds:
|
||||
# 1. Retry with network settings
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --network=host --load .
|
||||
|
||||
# 2. Build one platform at a time if you're having resource issues
|
||||
docker buildx build --platform linux/amd64 -t gitea-mirror:amd64 --load .
|
||||
docker buildx build --platform linux/arm64 -t gitea-mirror:arm64 --load .
|
||||
|
||||
# Create a named volume for database persistence
|
||||
docker volume create gitea-mirror-data
|
||||
```
|
||||
|
||||
##### Environment Variables
|
||||
|
||||
The Docker container can be configured with the following environment variables:
|
||||
|
||||
- `DATABASE_URL`: SQLite database URL (default: `file:data/gitea-mirror.db`)
|
||||
- `HOST`: Host to bind to (default: `0.0.0.0`)
|
||||
- `PORT`: Port to listen on (default: `4321`)
|
||||
- `JWT_SECRET`: Secret key for JWT token generation (auto-generated if not provided)
|
||||
|
||||
> [!TIP]
|
||||
> For security, Gitea Mirror will automatically generate a secure random JWT secret on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
|
||||
|
||||
#### Manual Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
# Clone repository
|
||||
git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
cd gitea-mirror
|
||||
|
||||
# Quick setup (installs dependencies and initializes the database)
|
||||
bun run setup
|
||||
# Start with simplified setup
|
||||
docker compose -f docker-compose.alt.yml up -d
|
||||
|
||||
# Development Mode Options
|
||||
|
||||
# Run in development mode
|
||||
bun run dev
|
||||
|
||||
# Run in development mode with clean database (removes existing DB first)
|
||||
bun run dev:clean
|
||||
|
||||
# Production Mode Options
|
||||
|
||||
# Build the application
|
||||
bun run build
|
||||
|
||||
# Preview the production build
|
||||
bun run preview
|
||||
|
||||
# Start the production server (default)
|
||||
bun run start
|
||||
|
||||
# Start the production server with a clean setup
|
||||
bun run start:fresh
|
||||
|
||||
# Database Management
|
||||
|
||||
# Initialize the database
|
||||
bun run init-db
|
||||
|
||||
# Reset users for testing first-time signup
|
||||
bun run reset-users
|
||||
|
||||
# Check database status
|
||||
bun run check-db
|
||||
# Access at http://localhost:4321
|
||||
```
|
||||
|
||||
##### Database Permissions for Direct Installation
|
||||
**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
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **If you're running the application directly** (not using Docker), you may encounter SQLite permission errors. This is because SQLite requires both read/write access to the database file and write access to the directory containing the database.
|
||||
**Best for:**
|
||||
- First-time users
|
||||
- Testing and evaluation
|
||||
- Simple deployments
|
||||
- When you prefer web-based configuration
|
||||
|
||||
**Common Error:**
|
||||
```
|
||||
Error: [ERROR] SQLiteError: attempt to write a readonly database
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ensure the data directory exists and has proper permissions
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
|
||||
# If the database file already exists, ensure it's writable
|
||||
chmod 644 data/gitea-mirror.db
|
||||
|
||||
# Make sure the user running the application owns the data directory
|
||||
chown -R $(whoami) data/
|
||||
```
|
||||
|
||||
**Why Docker doesn't have this issue:**
|
||||
- Docker containers run with a dedicated user (`gitea-mirror`) that owns the `/app/data` directory
|
||||
- The container setup ensures proper permissions are set during image build
|
||||
- Volume mounts are handled by Docker with appropriate permissions
|
||||
|
||||
**Recommended approach:** Use Docker or Docker Compose for deployment to avoid permission issues entirely.
|
||||
|
||||
### Configuration
|
||||
|
||||
Gitea Mirror can be configured through environment variables or through the web UI. See the [Configuration Guide](src/content/docs/configuration.md) for more details.
|
||||
|
||||
Key configuration options include:
|
||||
|
||||
- GitHub connection settings (username, token, repository filters)
|
||||
- Gitea connection settings (URL, token, organization)
|
||||
- Mirroring options (issues, starred repositories, organizations)
|
||||
- Scheduling options for automatic mirroring
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications.
|
||||
|
||||
### Mirror Strategies & Destination Customization
|
||||
|
||||
Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea, with fine-grained control over destinations:
|
||||
|
||||
#### 1. **Preserve GitHub Structure** (Default)
|
||||
- Personal repositories → Your Gitea username (or custom organization)
|
||||
- Organization repositories → Same organization name in Gitea (with individual overrides)
|
||||
- Maintains the exact structure from GitHub with optional customization
|
||||
|
||||
#### 2. **Single Organization**
|
||||
- All repositories → One designated organization
|
||||
- Simplifies management by consolidating everything
|
||||
- Requires specifying a destination organization name
|
||||
|
||||
#### 3. **Flat User Structure**
|
||||
- All repositories → Your Gitea user account
|
||||
- No organizations needed
|
||||
- Simplest approach for personal use
|
||||
|
||||
#### Destination Customization
|
||||
|
||||
**Organization-Level Overrides:**
|
||||
- Click the edit button on any organization card to set a custom destination
|
||||
- All repositories from that GitHub organization will be mirrored to your specified Gitea organization
|
||||
- Visual indicators show when custom destinations are active
|
||||
|
||||
**Repository-Level Overrides:**
|
||||
- Fine-tune individual repository destinations in the repository table
|
||||
- Click the edit button in the "Destination" column to customize where a specific repo is mirrored
|
||||
- Overrides organization-level settings for maximum flexibility
|
||||
- Starred repositories display a ⭐ icon and always go to the configured starred repos organization
|
||||
|
||||
**Priority Hierarchy:**
|
||||
1. Starred repositories → Always go to `starredReposOrg` (not editable)
|
||||
2. Repository-level custom destination (highest priority for non-starred)
|
||||
3. Organization-level custom destination
|
||||
4. Personal repos override (for non-organization repos)
|
||||
5. Default strategy rules (lowest priority)
|
||||
|
||||
> [!NOTE]
|
||||
> **Starred Repositories**: Repositories you've starred on GitHub are automatically organized into a separate organization (default: "starred") and cannot have custom destinations. They're marked with a ⭐ icon for easy identification.
|
||||
|
||||
> [!TIP]
|
||||
> **Example Use Cases**:
|
||||
> - Mirror personal repos to `personal-archive` organization
|
||||
> - Redirect `work-org` repos to `company-mirror` in Gitea
|
||||
> - Override a single important repo to go to a special organization
|
||||
> - Keep `company-org` repos in their own `company-org` organization
|
||||
> - Override `community-scripts` to go to `community-mirrors` organization
|
||||
> - This gives you complete control while maintaining GitHub's structure as the default
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
### Local Development Setup
|
||||
#### Option 2: Full Setup (docker-compose.yml)
|
||||
For production deployments with environment-based configuration:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun run setup
|
||||
# Start with full configuration options
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
# Start the development server
|
||||
**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
|
||||
|
||||
#### Quick Start Configuration (docker-compose.alt.yml)
|
||||
|
||||
Minimal `.env` file (optional - has sensible defaults):
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
|
||||
📚 **For a complete list of all supported environment variables, see the [Environment Variables Documentation](docs/ENVIRONMENT_VARIABLES.md).**
|
||||
|
||||
### LXC Container (Proxmox)
|
||||
|
||||
```bash
|
||||
# One-line install on Proxmox VE
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
|
||||
```
|
||||
|
||||
See the [Proxmox VE Community Scripts](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror) for more details.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Install Bun
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Setup and run
|
||||
bun run setup
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Setting Up a Local Gitea Instance for Testing
|
||||
1. **First Time Setup**
|
||||
- Navigate to http://localhost:4321
|
||||
- Create admin account (first user signup)
|
||||
- Configure GitHub and Gitea connections
|
||||
|
||||
For full end-to-end testing, you can set up a local Gitea instance using Docker:
|
||||
|
||||
```bash
|
||||
# Create a Docker network for Gitea and Gitea Mirror to communicate
|
||||
# Using the --label flag ensures proper Docker Compose compatibility
|
||||
docker network create --label com.docker.compose.network=gitea-network gitea-network
|
||||
|
||||
# Create volumes for Gitea data persistence
|
||||
docker volume create gitea-data
|
||||
docker volume create gitea-config
|
||||
|
||||
# Run Gitea container
|
||||
docker run -d \
|
||||
--name gitea \
|
||||
--network gitea-network \
|
||||
-p 3001:3000 \
|
||||
-p 2222:22 \
|
||||
-v gitea-data:/data \
|
||||
-v gitea-config:/etc/gitea \
|
||||
-e USER_UID=1000 \
|
||||
-e USER_GID=1000 \
|
||||
-e GITEA__database__DB_TYPE=sqlite3 \
|
||||
-e GITEA__database__PATH=/data/gitea/gitea.db \
|
||||
-e GITEA__server__DOMAIN=localhost \
|
||||
-e GITEA__server__ROOT_URL=http://localhost:3001/ \
|
||||
-e GITEA__server__SSH_DOMAIN=localhost \
|
||||
-e GITEA__server__SSH_PORT=2222 \
|
||||
-e GITEA__server__START_SSH_SERVER=true \
|
||||
-e GITEA__security__INSTALL_LOCK=false \
|
||||
-e GITEA__service__DISABLE_REGISTRATION=false \
|
||||
gitea/gitea:latest
|
||||
```
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> After Gitea is running:
|
||||
> 1. Access Gitea at http://localhost:3001/
|
||||
> 2. Register a new user
|
||||
> 3. Create a personal access token in Gitea (Settings > Applications > Generate New Token)
|
||||
> 4. Run Gitea Mirror with the local Gitea configuration:
|
||||
|
||||
```bash
|
||||
# Run Gitea Mirror connected to the local Gitea instance
|
||||
docker run -d \
|
||||
--name gitea-mirror-dev \
|
||||
--network gitea-network \
|
||||
-p 4321:4321 \
|
||||
-v gitea-mirror-data:/app/data \
|
||||
-e NODE_ENV=development \
|
||||
-e JWT_SECRET=dev-secret-key \
|
||||
-e GITHUB_TOKEN=your-github-token \
|
||||
-e GITHUB_USERNAME=your-github-username \
|
||||
-e GITEA_URL=http://gitea:3000 \
|
||||
-e GITEA_TOKEN=your-local-gitea-token \
|
||||
-e GITEA_USERNAME=your-local-gitea-username \
|
||||
RayLabsHQ/gitea-mirror:latest
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> This setup allows you to test the full mirroring functionality with a local Gitea instance.
|
||||
|
||||
### Using Docker Compose for Development
|
||||
|
||||
|
||||
For convenience, a dedicated development docker-compose file is provided that sets up both Gitea Mirror and a local Gitea instance:
|
||||
|
||||
```bash
|
||||
# Start with development environment and local Gitea instance
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> You can also create a `.env` file with your GitHub and Gitea credentials:
|
||||
>
|
||||
> ```env
|
||||
> # GitHub credentials
|
||||
> GITHUB_TOKEN=your-github-token
|
||||
> GITHUB_USERNAME=your-github-username
|
||||
>
|
||||
> # Gitea credentials (will be set up after you create a user in the local Gitea instance)
|
||||
> GITEA_TOKEN=your-local-gitea-token
|
||||
> GITEA_USERNAME=your-local-gitea-username
|
||||
> ```
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||
- **Backend**: Bun
|
||||
- **Database**: SQLite (handles both data storage and event notifications)
|
||||
- **API Integration**: GitHub API (Octokit), Gitea API
|
||||
- **Deployment Options**: Docker containers, LXC containers (Proxmox VE and local testing)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
2. **Mirror Strategies**
|
||||
- **Preserve Structure**: Maintains GitHub organization structure
|
||||
- **Single Organization**: All repos go to one Gitea organization
|
||||
- **Flat User**: All repos under your Gitea user account
|
||||
- **Mixed Mode**: Personal repos in one org, organization repos preserve structure
|
||||
|
||||
3. **Customization**
|
||||
- Click edit buttons on organization cards to set custom destinations
|
||||
- Override individual repository destinations in the table view
|
||||
- Starred repositories automatically go to a dedicated organization
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Compose Network Issues
|
||||
### Reverse Proxy Configuration
|
||||
|
||||
> [!WARNING]
|
||||
> If you encounter network-related warnings or errors when running Docker Compose, such as:
|
||||
>
|
||||
> ```
|
||||
> WARN[0095] a network with name gitea-network exists but was not created by compose.
|
||||
> Set `external: true` to use an existing network
|
||||
> ```
|
||||
>
|
||||
> or
|
||||
>
|
||||
> ```
|
||||
> network gitea-network was found but has incorrect label com.docker.compose.network set to "" (expected: "gitea-network")
|
||||
> ```
|
||||
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.
|
||||
|
||||
Try the following steps:
|
||||
## Development
|
||||
|
||||
1. Stop the current Docker Compose stack:
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
2. Remove the existing network:
|
||||
```bash
|
||||
docker network rm gitea-network
|
||||
```
|
||||
|
||||
3. Restart the Docker Compose stack:
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you need to share the network with other Docker Compose projects, you can modify the `docker-compose.dev.yml` file to mark the network as external:
|
||||
>
|
||||
> ```yaml
|
||||
> networks:
|
||||
> gitea-network:
|
||||
> name: gitea-network
|
||||
> external: true
|
||||
> ```
|
||||
|
||||
### Database Persistence
|
||||
|
||||
> [!TIP]
|
||||
> The application uses SQLite for all data storage and event notifications. Make sure the database file is properly mounted when using Docker:
|
||||
>
|
||||
> ```bash
|
||||
> # Run with a volume for persistent data storage
|
||||
> docker run -d -p 4321:4321 \
|
||||
> -v gitea-mirror-data:/app/data \
|
||||
> ghcr.io/RayLabsHQ/gitea-mirror:latest
|
||||
> ```
|
||||
>
|
||||
> For homelab/self-hosted setups, you can use the standard Docker Compose file which includes automatic database cleanup:
|
||||
>
|
||||
> ```bash
|
||||
> # Clone the repository
|
||||
> git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
> cd gitea-mirror
|
||||
>
|
||||
> # Start the application with Docker Compose
|
||||
> docker compose up -d
|
||||
> ```
|
||||
>
|
||||
> This setup provides a complete containerized deployment for the Gitea Mirror application.
|
||||
|
||||
#### Docker Volume Types and Permissions
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Named Volumes vs Bind Mounts**: If you encounter SQLite permission errors even when using Docker, check your volume configuration:
|
||||
|
||||
**✅ Named Volumes (Recommended):**
|
||||
```yaml
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data # Docker manages permissions automatically
|
||||
```
|
||||
|
||||
**⚠️ Bind Mounts (Requires Manual Permission Setup):**
|
||||
```yaml
|
||||
volumes:
|
||||
- /host/path/to/data:/app/data # Host filesystem permissions apply
|
||||
```
|
||||
|
||||
**If using bind mounts**, ensure the host directory is owned by UID 1001 (the `gitea-mirror` user):
|
||||
```bash
|
||||
# Set correct ownership for bind mount
|
||||
sudo chown -R 1001:1001 /host/path/to/data
|
||||
sudo chmod -R 755 /host/path/to/data
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run development server
|
||||
bun run dev
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
```
|
||||
|
||||
**Why named volumes work better:**
|
||||
- Docker automatically handles permissions
|
||||
- Better portability across different hosts
|
||||
- No manual permission setup required
|
||||
- Used by our official docker-compose.yml
|
||||
## Technologies
|
||||
|
||||
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||
- **Backend**: Bun runtime, SQLite, Drizzle ORM
|
||||
- **APIs**: GitHub (Octokit), Gitea REST API
|
||||
- **Auth**: Better Auth with session-based authentication
|
||||
|
||||
#### Database Maintenance
|
||||
## Security
|
||||
|
||||
> [!TIP]
|
||||
> For database maintenance, you can use the provided scripts:
|
||||
>
|
||||
> ```bash
|
||||
> # Check database integrity
|
||||
> bun run check-db
|
||||
>
|
||||
> # Fix database issues
|
||||
> bun run fix-db
|
||||
>
|
||||
> # Reset user accounts (for development)
|
||||
> bun run reset-users
|
||||
> ```
|
||||
>
|
||||
> **Note:** For cleaning up old activities and events, use the cleanup button in the Activity Log page of the web interface.
|
||||
### 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
|
||||
|
||||
> [!NOTE]
|
||||
> This implementation provides:
|
||||
> - Automatic retry with exponential backoff
|
||||
> - Better error logging
|
||||
> - Connection event handling
|
||||
> - Proper timeout settings
|
||||
## Authentication
|
||||
|
||||
Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.**
|
||||
|
||||
### Container Health Checks
|
||||
### 1. Email & Password (Default)
|
||||
The standard authentication method. First user to sign up becomes the admin.
|
||||
|
||||
> [!TIP]
|
||||
> If containers are not starting properly, check their health status:
|
||||
>
|
||||
> ```bash
|
||||
> docker ps --format "{{.Names}}: {{.Status}}"
|
||||
> ```
|
||||
>
|
||||
> For more detailed logs:
|
||||
>
|
||||
> ```bash
|
||||
> docker logs gitea-mirror-dev
|
||||
> ```
|
||||
### 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.
|
||||
|
||||
## Acknowledgements
|
||||
**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
|
||||
|
||||
- [Octokit](https://github.com/octokit/rest.js/) - GitHub REST API client for JavaScript
|
||||
- [Shadcn UI](https://ui.shadcn.com/) - For the beautiful UI components
|
||||
- [Astro](https://astro.build/) - For the excellent web framework
|
||||
- [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) - For the Proxmox VE installation script maintained by [CrazyWolf13](https://github.com/CrazyWolf13)
|
||||
**Redirect URL for your provider:**
|
||||
```
|
||||
https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
```
|
||||
|
||||
### 3. Header Authentication (Reverse Proxy)
|
||||
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.
|
||||
|
||||
**Environment Variables:**
|
||||
```bash
|
||||
# Enable header authentication
|
||||
HEADER_AUTH_ENABLED=true
|
||||
|
||||
# Header names (customize based on your proxy)
|
||||
HEADER_AUTH_USER_HEADER=X-Authentik-Username
|
||||
HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
|
||||
HEADER_AUTH_NAME_HEADER=X-Authentik-Name
|
||||
|
||||
# Auto-provision new users
|
||||
HEADER_AUTH_AUTO_PROVISION=true
|
||||
|
||||
# Restrict to specific email domains (optional)
|
||||
HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Users authenticated by your reverse proxy are automatically logged in
|
||||
- No additional login step required
|
||||
- New users can be auto-provisioned if enabled
|
||||
- Falls back to regular authentication if headers are missing
|
||||
|
||||
**Example Authentik Configuration:**
|
||||
```nginx
|
||||
# In your reverse proxy configuration
|
||||
proxy_set_header X-Authentik-Username $authentik_username;
|
||||
proxy_set_header X-Authentik-Email $authentik_email;
|
||||
proxy_set_header X-Authentik-Name $authentik_name;
|
||||
```
|
||||
|
||||
### 4. OAuth Applications (Act as Identity Provider)
|
||||
Gitea Mirror can also act as an OIDC provider for other applications. Register OAuth applications in Settings → Authentication & SSO → OAuth Applications tab.
|
||||
|
||||
**Use cases:**
|
||||
- Allow other services to authenticate using Gitea Mirror accounts
|
||||
- Create service-to-service authentication
|
||||
- Build integrations with your Gitea Mirror instance
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
||||
|
||||
## License
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
6
bunfig.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[test]
|
||||
# Set test timeout to 5 seconds (5000ms) to prevent hanging tests
|
||||
timeout = 5000
|
||||
|
||||
# Preload the setup file
|
||||
preload = ["./src/tests/setup.bun.ts"]
|
||||
236
certs/README.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# CA Certificates Configuration
|
||||
|
||||
This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
|
||||
|
||||
## Overview
|
||||
|
||||
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates.
|
||||
|
||||
## Common SSL/TLS Errors
|
||||
|
||||
If you encounter any of these errors, you need to configure CA certificates:
|
||||
|
||||
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
|
||||
- `SELF_SIGNED_CERT_IN_CHAIN`
|
||||
- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
|
||||
- `CERT_UNTRUSTED`
|
||||
- `unable to verify the first certificate`
|
||||
|
||||
## Configuration by Deployment Method
|
||||
|
||||
### Docker
|
||||
|
||||
#### Method 1: Volume Mount (Recommended)
|
||||
|
||||
1. Create a certificates directory:
|
||||
```bash
|
||||
mkdir -p ./certs
|
||||
```
|
||||
|
||||
2. Copy your CA certificate(s):
|
||||
```bash
|
||||
cp /path/to/your-ca-cert.crt ./certs/
|
||||
```
|
||||
|
||||
3. Update `docker-compose.yml`:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: raylabs/gitea-mirror:latest
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./certs:/usr/local/share/ca-certificates:ro
|
||||
environment:
|
||||
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
|
||||
```
|
||||
|
||||
4. Restart the container:
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
#### Method 2: Custom Docker Image
|
||||
|
||||
Create a `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM raylabs/gitea-mirror:latest
|
||||
|
||||
# Copy CA certificates
|
||||
COPY ./certs/*.crt /usr/local/share/ca-certificates/
|
||||
|
||||
# Update CA certificates
|
||||
RUN update-ca-certificates
|
||||
|
||||
# Set environment variable
|
||||
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
|
||||
```
|
||||
|
||||
Build and use:
|
||||
```bash
|
||||
docker build -t my-gitea-mirror .
|
||||
```
|
||||
|
||||
### Native/Bun
|
||||
|
||||
#### Method 1: Environment Variable
|
||||
|
||||
```bash
|
||||
export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
|
||||
bun run start
|
||||
```
|
||||
|
||||
#### Method 2: .env File
|
||||
|
||||
Add to your `.env` file:
|
||||
```
|
||||
NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
|
||||
```
|
||||
|
||||
#### Method 3: System CA Store
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
**RHEL/CentOS/Fedora:**
|
||||
```bash
|
||||
sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
|
||||
sudo update-ca-trust
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
sudo security add-trusted-cert -d -r trustRoot \
|
||||
-k /Library/Keychains/System.keychain your-ca-cert.crt
|
||||
```
|
||||
|
||||
### LXC Container (Proxmox VE)
|
||||
|
||||
1. Enter the container:
|
||||
```bash
|
||||
pct enter <container-id>
|
||||
```
|
||||
|
||||
2. Create certificates directory:
|
||||
```bash
|
||||
mkdir -p /usr/local/share/ca-certificates
|
||||
```
|
||||
|
||||
3. Copy your CA certificate:
|
||||
```bash
|
||||
cat > /usr/local/share/ca-certificates/your-ca.crt
|
||||
```
|
||||
(Paste certificate content and press Ctrl+D)
|
||||
|
||||
4. Update the systemd service:
|
||||
```bash
|
||||
cat >> /etc/systemd/system/gitea-mirror.service << EOF
|
||||
Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt"
|
||||
EOF
|
||||
```
|
||||
|
||||
5. Reload and restart:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl restart gitea-mirror
|
||||
```
|
||||
|
||||
## Multiple CA Certificates
|
||||
|
||||
### Option 1: Bundle Certificates
|
||||
|
||||
```bash
|
||||
cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
|
||||
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt
|
||||
```
|
||||
|
||||
### Option 2: System CA Store
|
||||
|
||||
```bash
|
||||
# Copy all certificates
|
||||
cp *.crt /usr/local/share/ca-certificates/
|
||||
update-ca-certificates
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Test Gitea Connection
|
||||
Use the "Test Connection" button in the Gitea configuration section.
|
||||
|
||||
### 2. Check Logs
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker logs gitea-mirror
|
||||
```
|
||||
|
||||
**Native:**
|
||||
Check terminal output
|
||||
|
||||
**LXC:**
|
||||
```bash
|
||||
journalctl -u gitea-mirror -f
|
||||
```
|
||||
|
||||
### 3. Manual Certificate Test
|
||||
|
||||
```bash
|
||||
openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Certificate Security**
|
||||
- Keep CA certificates secure
|
||||
- Use read-only mounts in Docker
|
||||
- Limit certificate file permissions
|
||||
- Regularly update certificates
|
||||
|
||||
2. **Certificate Management**
|
||||
- Use descriptive certificate filenames
|
||||
- Document certificate purposes
|
||||
- Track certificate expiration dates
|
||||
- Maintain certificate backups
|
||||
|
||||
3. **Production Deployment**
|
||||
- Use proper SSL certificates when possible
|
||||
- Consider Let's Encrypt for public instances
|
||||
- Implement certificate rotation procedures
|
||||
- Monitor certificate expiration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate not being recognized
|
||||
- Ensure the certificate is in PEM format
|
||||
- Check that `NODE_EXTRA_CA_CERTS` points to the correct file
|
||||
- Restart the application after adding certificates
|
||||
|
||||
### Still getting SSL errors
|
||||
- Verify the complete certificate chain is included
|
||||
- Check if intermediate certificates are needed
|
||||
- Ensure the certificate matches the server hostname
|
||||
|
||||
### Certificate expired
|
||||
- Check validity: `openssl x509 -in cert.crt -noout -dates`
|
||||
- Update with new certificate from your CA
|
||||
- Restart Gitea Mirror after updating
|
||||
|
||||
## Certificate Format
|
||||
|
||||
Certificates must be in PEM format. Example:
|
||||
|
||||
```
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAKl8bUgMdErlMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
[... certificate content ...]
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
If your certificate is in DER format, convert it:
|
||||
```bash
|
||||
openssl x509 -inform der -in certificate.cer -out certificate.crt
|
||||
```
|
||||
27
docker-compose.alt.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
# Gitea Mirror alternate deployment configuration
|
||||
# Standard deployment with host path and minimal environments
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
container_name: gitea-mirror
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-4321}:4321"
|
||||
user: ${PUID:-1000}:${PGID:-1000}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# For a complete list of all supported environment variables, see:
|
||||
# docs/ENVIRONMENT_VARIABLES.md or .env.example
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BETTER_AUTH_URL=http://localhost:4321
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
@@ -54,6 +54,11 @@ services:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
# Mount custom CA certificates - choose one option:
|
||||
# Option 1: Mount individual CA certificates from certs directory
|
||||
# - ./certs:/app/certs:ro
|
||||
# Option 2: Mount system CA bundle (if your CA is already in system store)
|
||||
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
depends_on:
|
||||
- gitea
|
||||
environment:
|
||||
@@ -61,10 +66,11 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- JWT_SECRET=dev-secret-key
|
||||
- BETTER_AUTH_SECRET=dev-secret-key
|
||||
# GitHub/Gitea Mirror Config
|
||||
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token}
|
||||
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
|
||||
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||
@@ -80,6 +86,8 @@ services:
|
||||
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
|
||||
17
docker-compose.keycloak.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
container_name: gitea-mirror-keycloak
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
command: start-dev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- keycloak_data:/opt/keycloak/data
|
||||
|
||||
volumes:
|
||||
keycloak_data:
|
||||
@@ -18,15 +18,26 @@ services:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- gitea-mirror-data:/app/data
|
||||
# Mount custom CA certificates - choose one option:
|
||||
# Option 1: Mount individual CA certificates from certs directory
|
||||
# - ./certs:/app/certs:ro
|
||||
# Option 2: Mount system CA bundle (if your CA is already in system store)
|
||||
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||
environment:
|
||||
# For a complete list of all supported environment variables, see:
|
||||
# docs/ENVIRONMENT_VARIABLES.md or .env.example
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
|
||||
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
|
||||
# GitHub/Gitea Mirror Config
|
||||
- GITHUB_USERNAME=${GITHUB_USERNAME:-}
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
||||
- GITHUB_EXCLUDED_ORGS=${GITHUB_EXCLUDED_ORGS:-}
|
||||
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||
@@ -42,6 +53,15 @@ services:
|
||||
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
# Header Authentication (for Reverse Proxy SSO)
|
||||
- HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false}
|
||||
- HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username}
|
||||
- HEADER_AUTH_EMAIL_HEADER=${HEADER_AUTH_EMAIL_HEADER:-X-Authentik-Email}
|
||||
- HEADER_AUTH_NAME_HEADER=${HEADER_AUTH_NAME_HEADER:-X-Authentik-Name}
|
||||
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
|
||||
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -5,15 +5,73 @@ set -e
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Generate a secure JWT secret if one isn't provided or is using the default value
|
||||
JWT_SECRET_FILE="/app/data/.jwt_secret"
|
||||
if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT_SECRET" ]; then
|
||||
# Handle custom CA certificates
|
||||
if [ -d "/app/certs" ] && [ "$(ls -A /app/certs/*.crt 2>/dev/null)" ]; then
|
||||
echo "Custom CA certificates found, configuring Node.js to use them..."
|
||||
|
||||
# Combine all CA certificates into a bundle for Node.js
|
||||
CA_BUNDLE="/app/certs/ca-bundle.crt"
|
||||
> "$CA_BUNDLE"
|
||||
|
||||
for cert in /app/certs/*.crt; do
|
||||
if [ -f "$cert" ]; then
|
||||
echo "Adding certificate: $(basename "$cert")"
|
||||
cat "$cert" >> "$CA_BUNDLE"
|
||||
echo "" >> "$CA_BUNDLE" # Add newline between certificates
|
||||
fi
|
||||
done
|
||||
|
||||
# Set Node.js to use the custom CA bundle
|
||||
export NODE_EXTRA_CA_CERTS="$CA_BUNDLE"
|
||||
echo "NODE_EXTRA_CA_CERTS set to: $NODE_EXTRA_CA_CERTS"
|
||||
|
||||
# For Bun compatibility, also set the CA bundle in system location if writable
|
||||
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ -w "/etc/ssl/certs/" ]; then
|
||||
echo "Appending custom certificates to system CA bundle..."
|
||||
cat "$CA_BUNDLE" >> /etc/ssl/certs/ca-certificates.crt
|
||||
fi
|
||||
|
||||
else
|
||||
echo "No custom CA certificates found in /app/certs"
|
||||
fi
|
||||
|
||||
# Check if system CA bundle is mounted and use it
|
||||
if [ -f "/etc/ssl/certs/ca-certificates.crt" ] && [ ! -L "/etc/ssl/certs/ca-certificates.crt" ]; then
|
||||
# Check if it's a mounted file (not the default symlink)
|
||||
if [ "$(stat -c '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -c '%d' / 2>/dev/null)" ] || \
|
||||
[ "$(stat -f '%d' /etc/ssl/certs/ca-certificates.crt 2>/dev/null)" != "$(stat -f '%d' / 2>/dev/null)" ]; then
|
||||
echo "System CA bundle mounted, configuring Node.js to use it..."
|
||||
export NODE_EXTRA_CA_CERTS="/etc/ssl/certs/ca-certificates.crt"
|
||||
echo "NODE_EXTRA_CA_CERTS set to: $NODE_EXTRA_CA_CERTS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optional: If GITEA_SKIP_TLS_VERIFY is set, configure accordingly
|
||||
if [ "$GITEA_SKIP_TLS_VERIFY" = "true" ]; then
|
||||
echo "Warning: GITEA_SKIP_TLS_VERIFY is set to true. This is insecure!"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
fi
|
||||
|
||||
# Generate a secure BETTER_AUTH_SECRET if one isn't provided or is using the default value
|
||||
BETTER_AUTH_SECRET_FILE="/app/data/.better_auth_secret"
|
||||
JWT_SECRET_FILE="/app/data/.jwt_secret" # Old file for backward compatibility
|
||||
|
||||
if [ "$BETTER_AUTH_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$BETTER_AUTH_SECRET" ]; then
|
||||
# Check if we have a previously generated secret
|
||||
if [ -f "$JWT_SECRET_FILE" ]; then
|
||||
echo "Using previously generated JWT secret"
|
||||
export JWT_SECRET=$(cat "$JWT_SECRET_FILE")
|
||||
if [ -f "$BETTER_AUTH_SECRET_FILE" ]; then
|
||||
echo "Using previously generated BETTER_AUTH_SECRET"
|
||||
export BETTER_AUTH_SECRET=$(cat "$BETTER_AUTH_SECRET_FILE")
|
||||
# Check for old JWT_SECRET file for backward compatibility
|
||||
elif [ -f "$JWT_SECRET_FILE" ]; then
|
||||
echo "Migrating from old JWT_SECRET to BETTER_AUTH_SECRET"
|
||||
export BETTER_AUTH_SECRET=$(cat "$JWT_SECRET_FILE")
|
||||
# Save to new file
|
||||
echo "$BETTER_AUTH_SECRET" > "$BETTER_AUTH_SECRET_FILE"
|
||||
chmod 600 "$BETTER_AUTH_SECRET_FILE"
|
||||
# Optionally remove old file after successful migration
|
||||
rm -f "$JWT_SECRET_FILE"
|
||||
else
|
||||
echo "Generating a secure random JWT secret"
|
||||
echo "Generating a secure random BETTER_AUTH_SECRET"
|
||||
# Try to generate a secure random string using OpenSSL
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
GENERATED_SECRET=$(openssl rand -hex 32)
|
||||
@@ -22,12 +80,38 @@ if [ "$JWT_SECRET" = "your-secret-key-change-this-in-production" ] || [ -z "$JWT
|
||||
echo "OpenSSL not found, using fallback method for random generation"
|
||||
GENERATED_SECRET=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
export JWT_SECRET="$GENERATED_SECRET"
|
||||
export BETTER_AUTH_SECRET="$GENERATED_SECRET"
|
||||
# Save the secret to a file for persistence across container restarts
|
||||
echo "$GENERATED_SECRET" > "$JWT_SECRET_FILE"
|
||||
chmod 600 "$JWT_SECRET_FILE"
|
||||
echo "$GENERATED_SECRET" > "$BETTER_AUTH_SECRET_FILE"
|
||||
chmod 600 "$BETTER_AUTH_SECRET_FILE"
|
||||
fi
|
||||
echo "JWT_SECRET has been set to a secure random value"
|
||||
echo "BETTER_AUTH_SECRET has been set to a secure random value"
|
||||
fi
|
||||
|
||||
# Generate a secure ENCRYPTION_SECRET if one isn't provided
|
||||
ENCRYPTION_SECRET_FILE="/app/data/.encryption_secret"
|
||||
|
||||
if [ -z "$ENCRYPTION_SECRET" ]; then
|
||||
# Check if we have a previously generated secret
|
||||
if [ -f "$ENCRYPTION_SECRET_FILE" ]; then
|
||||
echo "Using previously generated ENCRYPTION_SECRET"
|
||||
export ENCRYPTION_SECRET=$(cat "$ENCRYPTION_SECRET_FILE")
|
||||
else
|
||||
echo "Generating a secure random ENCRYPTION_SECRET"
|
||||
# Generate a 48-character secret for encryption
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
GENERATED_ENCRYPTION_SECRET=$(openssl rand -base64 36)
|
||||
else
|
||||
# Fallback to using /dev/urandom if openssl is not available
|
||||
echo "OpenSSL not found, using fallback method for encryption secret generation"
|
||||
GENERATED_ENCRYPTION_SECRET=$(head -c 36 /dev/urandom | base64 | tr -d '\n' | head -c 48)
|
||||
fi
|
||||
export ENCRYPTION_SECRET="$GENERATED_ENCRYPTION_SECRET"
|
||||
# Save the secret to a file for persistence across container restarts
|
||||
echo "$GENERATED_ENCRYPTION_SECRET" > "$ENCRYPTION_SECRET_FILE"
|
||||
chmod 600 "$ENCRYPTION_SECRET_FILE"
|
||||
fi
|
||||
echo "ENCRYPTION_SECRET has been set to a secure random value"
|
||||
fi
|
||||
|
||||
|
||||
@@ -185,19 +269,7 @@ else
|
||||
bun scripts/manage-db.ts fix
|
||||
fi
|
||||
|
||||
# Run database migrations
|
||||
echo "Running database migrations..."
|
||||
|
||||
# Update mirror_jobs table with new columns for resilience
|
||||
if [ -f "dist/scripts/update-mirror-jobs-table.js" ]; then
|
||||
echo "Updating mirror_jobs table..."
|
||||
bun dist/scripts/update-mirror-jobs-table.js
|
||||
elif [ -f "scripts/update-mirror-jobs-table.ts" ]; then
|
||||
echo "Updating mirror_jobs table using TypeScript script..."
|
||||
bun scripts/update-mirror-jobs-table.ts
|
||||
else
|
||||
echo "Warning: Could not find mirror_jobs table update script."
|
||||
fi
|
||||
echo "Database exists, checking integrity..."
|
||||
fi
|
||||
|
||||
# Extract version from package.json and set as environment variable
|
||||
@@ -208,6 +280,28 @@ fi
|
||||
|
||||
|
||||
|
||||
# Initialize configuration from environment variables if provided
|
||||
echo "Checking for environment configuration..."
|
||||
if [ -f "dist/scripts/startup-env-config.js" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun dist/scripts/startup-env-config.js
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
elif [ -f "scripts/startup-env-config.ts" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun scripts/startup-env-config.ts
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
else
|
||||
echo "Environment configuration script not found. Skipping."
|
||||
ENV_CONFIG_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log environment config result
|
||||
if [ $ENV_CONFIG_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Environment configuration loaded successfully"
|
||||
else
|
||||
echo "⚠️ Environment configuration loading completed with warnings"
|
||||
fi
|
||||
|
||||
# Run startup recovery to handle any interrupted jobs
|
||||
echo "Running startup recovery..."
|
||||
if [ -f "dist/scripts/startup-recovery.js" ]; then
|
||||
|
||||
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)
|
||||
1
docs/CA_CERTIFICATES.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../certs/README.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)
|
||||
299
docs/ENVIRONMENT_VARIABLES.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Environment Variables Documentation
|
||||
|
||||
This document provides a comprehensive list of all environment variables supported by Gitea Mirror. These can be used to configure the application via Docker or other deployment methods.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [GitHub Configuration](#github-configuration)
|
||||
- [Gitea Configuration](#gitea-configuration)
|
||||
- [Mirror Options](#mirror-options)
|
||||
- [Automation Configuration](#automation-configuration)
|
||||
- [Database Cleanup Configuration](#database-cleanup-configuration)
|
||||
- [Authentication Configuration](#authentication-configuration)
|
||||
- [Docker Configuration](#docker-configuration)
|
||||
|
||||
## Core Configuration
|
||||
|
||||
Essential application settings required for running Gitea Mirror.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `NODE_ENV` | Application environment | `production` | No |
|
||||
| `HOST` | Server host binding | `0.0.0.0` | No |
|
||||
| `PORT` | Server port | `4321` | No |
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Base URL for authentication | `http://localhost:4321` | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
## GitHub Configuration
|
||||
|
||||
Settings for connecting to and configuring GitHub repository sources.
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITHUB_USERNAME` | Your GitHub username | - | - |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
|
||||
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
|
||||
|
||||
### Repository Selection
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
|
||||
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
|
||||
### Organization Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
|
||||
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
|
||||
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SKIP_STARRED_ISSUES` | Enable lightweight mode for starred repos (skip issues) | `false` | `true`, `false` |
|
||||
|
||||
## Gitea Configuration
|
||||
|
||||
Settings for the destination Gitea instance.
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_URL` | Gitea instance URL | - | Valid URL |
|
||||
| `GITEA_TOKEN` | Gitea access token | - | - |
|
||||
| `GITEA_USERNAME` | Gitea username | - | - |
|
||||
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
|
||||
|
||||
### Repository Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_ORG_VISIBILITY` | Default organization visibility | `public` | `public`, `private`, `limited`, `default` |
|
||||
| `GITEA_MIRROR_INTERVAL` | Mirror sync interval | `8h` | Duration string (e.g., `30m`, `1h`, `8h`, `24h`) |
|
||||
| `GITEA_LFS` | Enable LFS support | `false` | `true`, `false` |
|
||||
| `GITEA_CREATE_ORG` | Auto-create organizations | `true` | `true`, `false` |
|
||||
| `GITEA_PRESERVE_VISIBILITY` | Preserve GitHub repo visibility in Gitea | `false` | `true`, `false` |
|
||||
|
||||
### Template Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_TEMPLATE_OWNER` | Template repository owner | - | Any string |
|
||||
| `GITEA_TEMPLATE_REPO` | Template repository name | - | Any string |
|
||||
|
||||
### Topic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_ADD_TOPICS` | Add topics to repositories | `true` | `true`, `false` |
|
||||
| `GITEA_TOPIC_PREFIX` | Prefix for repository topics | - | Any string |
|
||||
|
||||
### Fork Handling
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_FORK_STRATEGY` | How to handle forked repositories | `reference` | `skip`, `reference`, `full-copy` |
|
||||
|
||||
### Additional Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_SKIP_TLS_VERIFY` | Skip TLS certificate verification (WARNING: insecure) | `false` | `true`, `false` |
|
||||
|
||||
## Mirror Options
|
||||
|
||||
Control what content gets mirrored from GitHub to Gitea.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
|
||||
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
|
||||
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
|
||||
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_PULL_REQUESTS` | Mirror pull requests (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_LABELS` | Mirror labels (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
| `MIRROR_MILESTONES` | Mirror milestones (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
|
||||
## Automation Configuration
|
||||
|
||||
Configure automatic scheduled mirroring.
|
||||
|
||||
### Basic Schedule Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
|
||||
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_CONCURRENT` | Allow concurrent mirror operations | `false` | `true`, `false` |
|
||||
| `SCHEDULE_BATCH_SIZE` | Number of repos to process in parallel | `10` | Number |
|
||||
| `SCHEDULE_PAUSE_BETWEEN_BATCHES` | Pause between batches (milliseconds) | `5000` | Number |
|
||||
|
||||
### Retry Configuration
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_RETRY_ATTEMPTS` | Number of retry attempts | `3` | Number |
|
||||
| `SCHEDULE_RETRY_DELAY` | Delay between retries (milliseconds) | `60000` | Number |
|
||||
| `SCHEDULE_TIMEOUT` | Max time for a mirror operation (milliseconds) | `3600000` | Number |
|
||||
| `SCHEDULE_AUTO_RETRY` | Automatically retry failed operations | `true` | `true`, `false` |
|
||||
|
||||
### Update Detection
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ONLY_MIRROR_UPDATED` | Only mirror repos with updates | `false` | `true`, `false` |
|
||||
| `SCHEDULE_UPDATE_INTERVAL` | Check for updates interval (milliseconds) | `86400000` | Number |
|
||||
| `SCHEDULE_SKIP_RECENTLY_MIRRORED` | Skip recently mirrored repos | `true` | `true`, `false` |
|
||||
| `SCHEDULE_RECENT_THRESHOLD` | Skip if mirrored within this time (milliseconds) | `3600000` | Number |
|
||||
|
||||
### Maintenance & Notifications
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_CLEANUP_BEFORE_MIRROR` | Run cleanup before mirroring | `false` | `true`, `false` |
|
||||
| `SCHEDULE_NOTIFY_ON_FAILURE` | Send notifications on failure | `true` | `true`, `false` |
|
||||
| `SCHEDULE_NOTIFY_ON_SUCCESS` | Send notifications on success | `false` | `true`, `false` |
|
||||
| `SCHEDULE_LOG_LEVEL` | Logging level | `info` | `error`, `warn`, `info`, `debug` |
|
||||
| `SCHEDULE_TIMEZONE` | Timezone for scheduling | `UTC` | Valid timezone string |
|
||||
|
||||
## Database Cleanup Configuration
|
||||
|
||||
Configure automatic cleanup of old events and data.
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_ENABLED` | Enable automatic cleanup | `false` | `true`, `false` |
|
||||
| `CLEANUP_RETENTION_DAYS` | Days to keep events | `7` | Number |
|
||||
|
||||
### Repository Cleanup
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
||||
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub | `true` | `true`, `false` |
|
||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
|
||||
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_BATCH_SIZE` | Number of items to process per batch | `10` | Number |
|
||||
| `CLEANUP_PAUSE_BETWEEN_DELETES` | Pause between deletions (milliseconds) | `2000` | Number |
|
||||
|
||||
## Authentication Configuration
|
||||
|
||||
Configure authentication methods and SSO.
|
||||
|
||||
### Header Authentication (Reverse Proxy SSO)
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `HEADER_AUTH_ENABLED` | Enable header-based authentication | `false` | `true`, `false` |
|
||||
| `HEADER_AUTH_USER_HEADER` | Header containing username | `X-Authentik-Username` | Header name |
|
||||
| `HEADER_AUTH_EMAIL_HEADER` | Header containing email | `X-Authentik-Email` | Header name |
|
||||
| `HEADER_AUTH_NAME_HEADER` | Header containing display name | `X-Authentik-Name` | Header name |
|
||||
| `HEADER_AUTH_AUTO_PROVISION` | Auto-create users from headers | `false` | `true`, `false` |
|
||||
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed email domains | - | Comma-separated domains |
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
Settings specific to Docker deployments.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `DOCKER_REGISTRY` | Docker registry URL | `ghcr.io` | Registry URL |
|
||||
| `DOCKER_IMAGE` | Docker image name | `arunavo4/gitea-mirror` | Image name |
|
||||
| `DOCKER_TAG` | Docker image tag | `latest` | Tag name |
|
||||
|
||||
## Example Docker Compose Configuration
|
||||
|
||||
Here's an example of how to use these environment variables in a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
container_name: gitea-mirror
|
||||
environment:
|
||||
# Core Configuration
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- BETTER_AUTH_SECRET=your-secure-secret-here
|
||||
- BETTER_AUTH_URL=https://your-domain.com
|
||||
|
||||
# GitHub Configuration
|
||||
- GITHUB_USERNAME=your-username
|
||||
- GITHUB_TOKEN=ghp_your_token_here
|
||||
- PRIVATE_REPOSITORIES=true
|
||||
- MIRROR_STARRED=true
|
||||
- SKIP_FORKS=false
|
||||
|
||||
# Gitea Configuration
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_USERNAME=admin
|
||||
- GITEA_TOKEN=your-gitea-token
|
||||
- GITEA_ORGANIZATION=github-mirrors
|
||||
- GITEA_ORG_VISIBILITY=public
|
||||
|
||||
# Mirror Options
|
||||
- MIRROR_RELEASES=true
|
||||
- MIRROR_WIKI=true
|
||||
- MIRROR_METADATA=true
|
||||
- MIRROR_ISSUES=true
|
||||
- MIRROR_PULL_REQUESTS=true
|
||||
|
||||
# Automation
|
||||
- SCHEDULE_ENABLED=true
|
||||
- SCHEDULE_INTERVAL=3600
|
||||
|
||||
# Cleanup
|
||||
- CLEANUP_ENABLED=true
|
||||
- CLEANUP_RETENTION_DAYS=30
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "4321:4321"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. **First Run**: Environment variables are loaded when the container starts. The configuration is applied after the first user account is created.
|
||||
|
||||
2. **UI Priority**: Manual changes made through the web UI will be preserved. Environment variables only set values for empty fields.
|
||||
|
||||
3. **Token Security**: All tokens are encrypted before being stored in the database.
|
||||
|
||||
4. **Backward Compatibility**: The `DELAY` variable is maintained for backward compatibility but `SCHEDULE_INTERVAL` is preferred.
|
||||
|
||||
5. **Required Scopes**: The GitHub token requires the following scopes:
|
||||
- `repo` (full control of private repositories)
|
||||
- `admin:org` (read organization data)
|
||||
- Additional scopes may be required for specific features
|
||||
|
||||
For more examples and detailed configuration, see the `.env.example` file in the repository.
|
||||
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
|
||||
193
docs/SSO_TESTING.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Local SSO Testing Guide
|
||||
|
||||
This guide explains how to test SSO authentication locally with Gitea Mirror.
|
||||
|
||||
## Option 1: Using Google OAuth (Recommended for Quick Testing)
|
||||
|
||||
### Setup Steps:
|
||||
|
||||
1. **Create a Google OAuth Application**
|
||||
- Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
- Create a new project or select existing
|
||||
- Enable Google+ API
|
||||
- Go to "Credentials" → "Create Credentials" → "OAuth client ID"
|
||||
- Choose "Web application"
|
||||
- Add authorized redirect URIs:
|
||||
- `http://localhost:3000/api/auth/sso/callback/google-sso`
|
||||
- `http://localhost:9876/api/auth/sso/callback/google-sso`
|
||||
|
||||
2. **Configure in Gitea Mirror**
|
||||
- Go to Configuration → Authentication tab
|
||||
- Click "Add Provider"
|
||||
- Select "OIDC / OAuth2"
|
||||
- Fill in:
|
||||
- Provider ID: `google-sso`
|
||||
- Email Domain: `gmail.com` (or your domain)
|
||||
- Issuer URL: `https://accounts.google.com`
|
||||
- Click "Discover" to auto-fill endpoints
|
||||
- Client ID: (from Google Console)
|
||||
- Client Secret: (from Google Console)
|
||||
- Save the provider
|
||||
|
||||
## Option 2: Using Keycloak (Local Identity Provider)
|
||||
|
||||
### Setup with Docker:
|
||||
|
||||
```bash
|
||||
# Run Keycloak
|
||||
docker run -d --name keycloak \
|
||||
-p 8080:8080 \
|
||||
-e KEYCLOAK_ADMIN=admin \
|
||||
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:latest start-dev
|
||||
|
||||
# Access at http://localhost:8080
|
||||
# Login with admin/admin
|
||||
```
|
||||
|
||||
### Configure Keycloak:
|
||||
|
||||
1. Create a new realm (e.g., "gitea-mirror")
|
||||
2. Create a client:
|
||||
- Client ID: `gitea-mirror`
|
||||
- Client Protocol: `openid-connect`
|
||||
- Access Type: `confidential`
|
||||
- Valid Redirect URIs: `http://localhost:*/api/auth/sso/callback/keycloak`
|
||||
3. Get credentials from the "Credentials" tab
|
||||
4. Create test users in "Users" section
|
||||
|
||||
### Configure in Gitea Mirror:
|
||||
|
||||
- Provider ID: `keycloak`
|
||||
- Email Domain: `example.com`
|
||||
- Issuer URL: `http://localhost:8080/realms/gitea-mirror`
|
||||
- Client ID: `gitea-mirror`
|
||||
- Client Secret: (from Keycloak)
|
||||
- Click "Discover" to auto-fill endpoints
|
||||
|
||||
## Option 3: Using Mock SSO Provider (Development)
|
||||
|
||||
For testing without external dependencies, you can use a mock OIDC provider.
|
||||
|
||||
### Using oidc-provider-example:
|
||||
|
||||
```bash
|
||||
# Clone and run mock provider
|
||||
git clone https://github.com/panva/node-oidc-provider-example.git
|
||||
cd node-oidc-provider-example
|
||||
npm install
|
||||
npm start
|
||||
|
||||
# Runs on http://localhost:3001
|
||||
```
|
||||
|
||||
### Configure in Gitea Mirror:
|
||||
|
||||
- Provider ID: `mock-provider`
|
||||
- Email Domain: `test.com`
|
||||
- Issuer URL: `http://localhost:3001`
|
||||
- Client ID: `foo`
|
||||
- Client Secret: `bar`
|
||||
- Authorization Endpoint: `http://localhost:3001/auth`
|
||||
- Token Endpoint: `http://localhost:3001/token`
|
||||
|
||||
## Testing the SSO Flow
|
||||
|
||||
1. **Logout** from Gitea Mirror if logged in
|
||||
2. Go to `/login`
|
||||
3. Click on the **SSO** tab
|
||||
4. Either:
|
||||
- Click the provider button (e.g., "Sign in with gmail.com")
|
||||
- Or enter your email and click "Continue with SSO"
|
||||
5. You'll be redirected to the identity provider
|
||||
6. Complete authentication
|
||||
7. You'll be redirected back and logged in
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **"Invalid origin" error**
|
||||
- Check that `trustedOrigins` in `/src/lib/auth.ts` includes your dev URL
|
||||
- Restart the dev server after changes
|
||||
|
||||
2. **Provider not showing in login**
|
||||
- Check browser console for errors
|
||||
- Verify provider was saved successfully
|
||||
- Check `/api/sso/providers` returns your providers
|
||||
|
||||
3. **Redirect URI mismatch**
|
||||
- Ensure the redirect URI in your OAuth app matches exactly:
|
||||
`http://localhost:PORT/api/auth/sso/callback/PROVIDER_ID`
|
||||
|
||||
4. **CORS errors**
|
||||
- Add your identity provider domain to CORS allowed origins if needed
|
||||
|
||||
### Debug Mode:
|
||||
|
||||
Enable debug logging by setting environment variable:
|
||||
```bash
|
||||
DEBUG=better-auth:* bun run dev
|
||||
```
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### 1. New User Registration
|
||||
- Use an email not in the system
|
||||
- SSO should create a new user automatically
|
||||
|
||||
### 2. Existing User Login
|
||||
- Create a user with email/password first
|
||||
- Login with SSO using same email
|
||||
- Should link to existing account
|
||||
|
||||
### 3. Domain-based Routing
|
||||
- Configure multiple providers with different domains
|
||||
- Test that entering email routes to correct provider
|
||||
|
||||
### 4. Organization Provisioning
|
||||
- Set organizationId on provider
|
||||
- Test that users are added to correct organization
|
||||
|
||||
## Security Testing
|
||||
|
||||
1. **Token Expiration**
|
||||
- Wait for session to expire
|
||||
- Test refresh flow
|
||||
|
||||
2. **Invalid State**
|
||||
- Modify state parameter in callback
|
||||
- Should reject authentication
|
||||
|
||||
3. **PKCE Flow**
|
||||
- Enable/disable PKCE
|
||||
- Verify code challenge works
|
||||
|
||||
## Using with Better Auth CLI
|
||||
|
||||
Better Auth provides CLI tools for testing:
|
||||
|
||||
```bash
|
||||
# List registered providers
|
||||
bun run auth:providers list
|
||||
|
||||
# Test provider configuration
|
||||
bun run auth:providers test google-sso
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For production-like testing:
|
||||
|
||||
```env
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=your-secret-key
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After successful SSO setup:
|
||||
1. Test user attribute mapping
|
||||
2. Configure role-based access
|
||||
3. Set up SAML if needed
|
||||
4. Test with your organization's actual IdP
|
||||
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`);
|
||||
10
drizzle/0002_bored_captain_cross.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `verifications` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_verifications_identifier` ON `verifications` (`identifier`);
|
||||
1290
drizzle/meta/0000_snapshot.json
Normal file
1722
drizzle/meta/0001_snapshot.json
Normal file
1784
drizzle/meta/0002_snapshot.json
Normal file
27
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1753539600567,
|
||||
"tag": "0002_bored_captain_cross",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
89
keycloak-sso-setup.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Keycloak SSO Setup for Gitea Mirror
|
||||
|
||||
## 1. Access Keycloak Admin Console
|
||||
|
||||
1. Open http://localhost:8080
|
||||
2. Login with:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## 2. Create a New Realm (Optional)
|
||||
|
||||
1. Click on the realm dropdown (top-left, probably says "master")
|
||||
2. Click "Create Realm"
|
||||
3. Name it: `gitea-mirror`
|
||||
4. Click "Create"
|
||||
|
||||
## 3. Create a Client for Gitea Mirror
|
||||
|
||||
1. Go to "Clients" in the left menu
|
||||
2. Click "Create client"
|
||||
3. Fill in:
|
||||
- Client type: `OpenID Connect`
|
||||
- Client ID: `gitea-mirror`
|
||||
- Name: `Gitea Mirror Application`
|
||||
4. Click "Next"
|
||||
5. Enable:
|
||||
- Client authentication: `ON`
|
||||
- Authorization: `OFF`
|
||||
- Standard flow: `ON`
|
||||
- Direct access grants: `OFF`
|
||||
6. Click "Next"
|
||||
7. Set the following URLs:
|
||||
- Root URL: `http://localhost:4321`
|
||||
- Valid redirect URIs: `http://localhost:4321/api/auth/sso/callback/keycloak`
|
||||
- Valid post logout redirect URIs: `http://localhost:4321`
|
||||
- Web origins: `http://localhost:4321`
|
||||
8. Click "Save"
|
||||
|
||||
## 4. Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab of your client
|
||||
2. Copy the "Client secret"
|
||||
|
||||
## 5. Configure Keycloak SSO in Gitea Mirror
|
||||
|
||||
1. Go to your Gitea Mirror settings: http://localhost:4321/settings
|
||||
2. Navigate to "Authentication" → "SSO Settings"
|
||||
3. Click "Add SSO Provider"
|
||||
4. Fill in:
|
||||
- **Provider ID**: `keycloak`
|
||||
- **Issuer URL**: `http://localhost:8080/realms/master` (or `http://localhost:8080/realms/gitea-mirror` if you created a new realm)
|
||||
- **Client ID**: `gitea-mirror`
|
||||
- **Client Secret**: (paste the secret from step 4)
|
||||
- **Email Domain**: Leave empty or set a specific domain to restrict access
|
||||
- **Scopes**: Select the scopes you want to test:
|
||||
- `openid` (required)
|
||||
- `profile`
|
||||
- `email`
|
||||
- `offline_access` (Keycloak supports this!)
|
||||
|
||||
## 6. Optional: Create Test Users in Keycloak
|
||||
|
||||
1. Go to "Users" in the left menu
|
||||
2. Click "Add user"
|
||||
3. Fill in:
|
||||
- Username: `testuser`
|
||||
- Email: `testuser@example.com`
|
||||
- Email verified: `ON`
|
||||
4. Click "Create"
|
||||
5. Go to "Credentials" tab
|
||||
6. Click "Set password"
|
||||
7. Set a password and turn off "Temporary"
|
||||
|
||||
## 7. Test SSO Login
|
||||
|
||||
1. Logout from Gitea Mirror if you're logged in
|
||||
2. Go to the login page: http://localhost:4321/login
|
||||
3. Click "Continue with SSO"
|
||||
4. Enter the email address (e.g., `testuser@example.com`)
|
||||
5. You'll be redirected to Keycloak
|
||||
6. Login with your Keycloak user credentials
|
||||
7. You should be redirected back to Gitea Mirror and logged in!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If you get SSL/TLS errors, make sure you're using the correct URLs (http for both Keycloak and Gitea Mirror)
|
||||
- Check the browser console and network tab for any errors
|
||||
- Keycloak logs: `docker logs gitea-mirror-keycloak`
|
||||
- The `offline_access` scope should work with Keycloak (unlike Google)
|
||||
9087
package-lock.json
generated
Normal file
57
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "2.20.0",
|
||||
"version": "3.2.5",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -16,8 +16,15 @@
|
||||
"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",
|
||||
"startup-env-config": "bun scripts/startup-env-config.ts",
|
||||
"test-recovery": "bun scripts/test-recovery.ts",
|
||||
"test-recovery-cleanup": "bun scripts/test-recovery.ts --cleanup",
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
@@ -32,9 +39,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/mdx": "4.3.3",
|
||||
"@astrojs/node": "9.3.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.4",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -53,43 +61,48 @@
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-virtual": "^3.13.10",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"astro": "^5.9.3",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"astro": "5.12.8",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.4",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.64"
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/bun": "^1.2.19",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.3",
|
||||
"vitest": "^3.2.3"
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "bun@1.2.16"
|
||||
"packageManager": "bun@1.2.19"
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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();
|
||||
}
|
||||
52
scripts/startup-env-config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Startup environment configuration script
|
||||
* This script loads configuration from environment variables before the application starts
|
||||
* It ensures that Docker environment variables are properly populated in the database
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/startup-env-config.ts
|
||||
*/
|
||||
|
||||
import { initializeConfigFromEnv } from "../src/lib/env-config-loader";
|
||||
|
||||
async function runEnvConfigInitialization() {
|
||||
console.log('=== Gitea Mirror Environment Configuration ===');
|
||||
console.log('Loading configuration from environment variables...');
|
||||
console.log('');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await initializeConfigFromEnv();
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.log(`✅ Environment configuration loaded successfully in ${duration}ms`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.error(`❌ Failed to load environment configuration after ${duration}ms:`, error);
|
||||
console.error('Application will start anyway, but environment configuration was not loaded.');
|
||||
|
||||
// Exit with error code but allow startup to continue
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle process signals gracefully
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⚠️ Configuration loading interrupted by SIGINT');
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n⚠️ Configuration loading interrupted by SIGTERM');
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// Run the environment configuration initialization
|
||||
runEnvConfigInitialization();
|
||||
@@ -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,73 @@ 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, providerId?: string) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!domain && !ssoEmail) {
|
||||
toast.error('Please enter your email or select a provider');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: `${baseURL}/`,
|
||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
@@ -61,14 +84,9 @@ export function LoginForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
|
||||
@@ -76,45 +94,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, provider.providerId)}
|
||||
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(undefined, undefined)}
|
||||
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 {
|
||||
@@ -66,14 +54,9 @@ export function SignupForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
src="/logo.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.svg"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-10 w-10 hidden dark:block"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
|
||||
@@ -84,20 +67,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 +79,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>
|
||||
{(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
if (config.mirrorStrategy) return config.mirrorStrategy;
|
||||
// Check for mixed mode: when we have both organization and personalReposOrg defined
|
||||
if (config.organization && config.personalReposOrg && !config.preserveOrgStructure) return "mixed";
|
||||
if (config.preserveOrgStructure) return "preserve";
|
||||
if (config.organization && config.organization !== config.username) return "single-org";
|
||||
return "flat-user";
|
||||
@@ -42,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;
|
||||
@@ -55,6 +59,18 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
newConfig.mirrorStrategy = "flat-user";
|
||||
newConfig.organization = "";
|
||||
break;
|
||||
case "mixed":
|
||||
newConfig.preserveOrgStructure = false;
|
||||
newConfig.mirrorStrategy = "mixed";
|
||||
// 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";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setConfig(newConfig);
|
||||
@@ -124,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>
|
||||
@@ -240,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>
|
||||
|
||||
725
src/components/config/SSOSettings.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
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 { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
|
||||
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';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
const url = new URL(issuer);
|
||||
return allowedHosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`));
|
||||
} catch {
|
||||
return false; // Return false if the URL is invalid
|
||||
}
|
||||
}
|
||||
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 [addingProvider, setAddingProvider] = useState(false);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
|
||||
|
||||
// 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'] as string[],
|
||||
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[] | { providers: SSOProvider[] }>('/sso/providers'),
|
||||
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
|
||||
]);
|
||||
|
||||
setProviders(Array.isArray(providersRes) ? providersRes : providersRes?.providers || []);
|
||||
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 () => {
|
||||
setAddingProvider(true);
|
||||
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;
|
||||
}
|
||||
|
||||
if (editingProvider) {
|
||||
// Update existing provider
|
||||
const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
|
||||
method: 'PUT',
|
||||
data: requestData,
|
||||
});
|
||||
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
|
||||
toast.success('SSO provider updated successfully');
|
||||
} else {
|
||||
// Create new provider
|
||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
||||
method: 'POST',
|
||||
data: requestData,
|
||||
});
|
||||
setProviders([...providers, newProvider]);
|
||||
toast.success('SSO provider created successfully');
|
||||
}
|
||||
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setAddingProvider(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditProvider = (provider: SSOProvider) => {
|
||||
setEditingProvider(provider);
|
||||
setProviderType(provider.samlConfig ? 'saml' : 'oidc');
|
||||
|
||||
if (provider.oidcConfig) {
|
||||
setProviderForm({
|
||||
...providerForm,
|
||||
providerId: provider.providerId,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
organizationId: provider.organizationId || '',
|
||||
clientId: provider.oidcConfig.clientId || '',
|
||||
clientSecret: provider.oidcConfig.clientSecret || '',
|
||||
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint || '',
|
||||
tokenEndpoint: provider.oidcConfig.tokenEndpoint || '',
|
||||
jwksEndpoint: provider.oidcConfig.jwksEndpoint || '',
|
||||
userInfoEndpoint: provider.oidcConfig.userInfoEndpoint || '',
|
||||
discoveryEndpoint: provider.oidcConfig.discoveryEndpoint || '',
|
||||
scopes: provider.oidcConfig.scopes || ['openid', 'email', 'profile'],
|
||||
pkce: provider.oidcConfig.pkce !== false,
|
||||
});
|
||||
}
|
||||
|
||||
setShowProviderDialog(true);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
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>
|
||||
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">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 className="text-lg font-semibold">External Identity Providers</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
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>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
? 'Update the configuration for this identity provider'
|
||||
: '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"
|
||||
disabled={!!editingProvider}
|
||||
/>
|
||||
</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="space-y-2">
|
||||
<Label htmlFor="scopes">OAuth Scopes</Label>
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "OpenID", value: "openid" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Profile", value: "profile" },
|
||||
{ label: "Offline Access", value: "offline_access" },
|
||||
]}
|
||||
selected={providerForm.scopes}
|
||||
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
|
||||
placeholder="Select scopes..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the OAuth scopes to request from the provider
|
||||
</p>
|
||||
</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>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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);
|
||||
setEditingProvider(null);
|
||||
// Reset form
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createProvider} disabled={addingProvider}>
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{editingProvider ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
editingProvider ? 'Update Provider' : '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-3">
|
||||
{providers.map(provider => (
|
||||
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.issuer}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
|
||||
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{provider.oidcConfig.scopes.map(scope => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider.samlConfig && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.organizationId && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
|
||||
<span className="text-muted-foreground">{provider.organizationId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => startEditProvider(provider)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteProvider(provider.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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,47 @@ 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.png"
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
<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 +111,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 +120,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,13 +44,14 @@ 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,
|
||||
enabled: user?.syncEnabled,
|
||||
interval: user?.syncInterval,
|
||||
lastSync: user?.lastSync,
|
||||
nextSync: user?.nextSync,
|
||||
enabled: false, // TODO: Get from config
|
||||
interval: 3600, // TODO: Get from config
|
||||
lastSync: null,
|
||||
nextSync: null,
|
||||
});
|
||||
|
||||
// Handle navigation from sidebar
|
||||
@@ -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,153 @@ 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 ?? undefined}
|
||||
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 ?? undefined}
|
||||
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 - TODO: Add these properties to Organization type */}
|
||||
{/* Commented out until repository count breakdown is available
|
||||
{isLoading || (org.status === "mirroring") ? (
|
||||
<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">
|
||||
</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 +375,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>
|
||||
);
|
||||
})}
|
||||
|
||||