Compare commits

...
137 Commits
Author SHA1 Message Date
Arunavo Ray 8446811e8d chore: bump version to 3.27.2 2026-08-21 17:37:40 +05:30
ARUNAVO RAY 0c41fac9c0 fix(auth): trust registered SSO provider origins and honor deleteFromGitea (#366) (#367)
Three fixes for the two problems reported in #366:

1. Auto-trust registered SSO identity provider origins. better-auth
   1.6.23 (shipped in v3.21.0) added SSRF hardening to the SSO plugin:
   sign-in rejects IdP endpoints whose hostnames resolve to private
   addresses unless the origin is in trustedOrigins. Homelab split-DNS
   setups (IdP domain resolving to a LAN IP from inside the container)
   broke on every sign-in with a 400. Registering a provider is an
   explicit operator action, so its issuer and endpoint origins are now
   added to trusted origins automatically.

2. Surface SSO sign-in errors in the login form. The auth client
   resolves with { data, error } instead of throwing, so server-side
   rejections were silently swallowed - the button flipped back from
   "Redirecting..." with no feedback and nothing in the logs.

3. Honor deleteFromGitea (CLEANUP_DELETE_FROM_GITEA). It was documented
   as "Delete repositories from Gitea" defaulting to false, but cleanup
   never read it and always archived/deleted orphans on the Gitea side.
   It now gates the Gitea-side operation: when disabled (default),
   orphans are only marked archived or removed in gitea-mirror's own
   database and the Gitea/Forgejo copies stay untouched.

Verified end to end against a live server: sign-in with a private-IP
IdP returns 400 discovery_private_host on v3.27.1 and a 200 with the
authorization URL on this branch; the login form now shows the server
error as a toast; delete cleanup with the flag off removes only the DB
row while the flag on contacts Gitea.
2026-08-21 17:37:32 +05:30
Arunavo Ray f18fc4bfe6 chore: bump version to 3.27.1 2026-08-21 07:09:35 +05:30
ARUNAVO RAY d63d55f52d fix(ui): surface Mirror Options on mobile repo cards and desktop org menu (#365)
The per-repo overrides from #361 were reachable only from the desktop
repositories table. The mobile card layout had no dropdown at all, and
the organizations page had the inverse gap: the item existed in the
mobile card menu but not the desktop one.

Mobile repo cards get a three-dot menu in the header opening the same
MirrorOverridesDialog, plus the Custom badge shown when a repository
overrides something. The organizations desktop dropdown gains the same
Mirror Options item the mobile variant already had.
2026-08-21 07:09:32 +05:30
ARUNAVO RAY 2867076d71 docs(www): align use-case articles with the actual configuration UI (#364)
Four older articles directed readers to UI elements by names that no
longer exist. Verified against src/components/config/:

  "Content & Data" section        -> "Mirror Content"
  "Mirror metadata" toggle        -> "Repository metadata"
  "Git LFS (Large File Storage)"  -> "Git LFS objects"
  "Mirror releases"               -> "Releases & tags"
  "Preserve structure" strategy   -> "Preserve Structure"

The new articles from #363 already used current names or generic
wording, so only the older four needed changes.
2026-08-20 06:41:41 +05:30
ARUNAVO RAY 7a935ff1b1 feat(www): SEO overhaul with sitemap, metadata, interlinking, and five new articles (#363)
* feat(www): generated sitemap, article metadata, and de-orphaned comparison page

The hand-written sitemap listed one URL of 24 and was dated January 2025.
astro.config now sets `site` and uses @astrojs/sitemap, so every built
page lands in sitemap-index.xml on each build. robots.txt points at the
new index and drops the duplicate user-agent block.

Use-case layouts gain the OG/Twitter tags the docs layout already had,
and all three article layouts now emit TechArticle JSON-LD.

Nothing on the site linked to /comparison/github-backup-tools/. It now
has a header nav item, a footer link, and a homepage CTA next to the
use-cases button. The footer Documentation link points at the site docs
instead of the GitHub tree, and footer internal links no longer open in
new tabs.

* feat(www): five new articles and an interlinking pass

New guides, each grounded in shipped behavior:

- Migrate from GitHub to Gitea (parallel-run migration playbook)
- Mirror Git LFS repositories (why one bad LFS repo fails its whole
  migration, and the per-repo override from v3.27.0 that fixes it)
- GitHub API rate limits (ETag conditional requests making 304s free)
- Mirror issues and pull requests (what transfers, the PR-to-issue
  conversion, why processing is sequential)
- GitHub backup on Unraid (based on the RayLabsHQ CA template)

All five are registered in the use-cases index; appended after the
existing entries so the homepage's featured three stay unchanged.

Interlinking pass across the existing articles: contextual inline
links where the text already referenced a topic, plus a short related
guides section per article. The comparison page's next steps now cover
the new guides, and the docs quickstart bridges to the use-case section.
Every article now carries 4 to 9 internal links; a build-time check
found 0 broken links across 440.
2026-08-20 06:33:04 +05:30
Arunavo Ray 85b72e9366 chore: bump version to 3.27.0 2026-08-20 06:03:34 +05:30
ARUNAVO RAY 0f700c7197 feat: per-repository and per-organization mirror option overrides (#362)
* feat: per-repository and per-organization mirror option overrides

Closes the gap reported in #361: a repository whose LFS fetch fails could
not be mirrored at all, because LFS was a single global switch. Gitea runs
the LFS fetch inside its own migration, so a failure aborts the whole
migration with nothing to salvage. The only fix available to us is to stop
asking for LFS on that repository.

Mirror options now resolve across three tiers, per flag, most specific
first: repository override, then organization override, then the global
config. NULL at a tier means inherit, so overriding LFS on one repo leaves
its other options alone.

Adds resolveMirrorOptions(), which replaces 24 scattered reads of
config.giteaConfig.* across three near-identical blocks: the two mirror
paths in gitea.ts and the sync path in gitea-enhanced.ts. The third was
easy to miss and mattered: without it, overrides would have been honored
on first mirror and silently ignored on every scheduled sync afterwards.

The starredCodeOnly clamp is folded into the resolver and deliberately
outranks explicit overrides, preserving the existing behavior that starred
repos mirror code only.

Editing is on the objects themselves, via the existing three-dot menus on
the Repositories and Organizations pages, not in Configuration, which keeps
holding the global defaults. A repositories filter and a row badge make it
possible to find which repos deviate.

Malformed override JSON degrades to "inherit" rather than throwing, so a
bad value can never break a mirror run.

* fix(ui): disable mirror toggles that cannot take effect, and explain why

A toggle the runtime will ignore should not look editable. Three cases
were doing exactly that, so they now share one mechanism:
getMirrorOverrideGating() returns a reason string per flag, and the dialog
disables the control and prints that reason underneath.

Starred clamp. When a repo is starred and starredCodeOnly is set, the
resolver forces every metadata flag off regardless of the override. The
dialog previously let the user set them anyway. The clamped set now lives
in STARRED_CLAMPED_KEYS, shared by the resolver and the gating helper so
the flags the UI disables cannot drift from the ones the runtime clamps.
LFS is deliberately excluded from that set, since turning LFS off per
repository is the point of #361, and a test pins that.

Labels. shouldMirrorLabels is `mirrorLabels && !mirrorIssues` in both
mirror paths, so labels cannot take effect while issues are mirrored. That
gate reacts live to the in-progress edit rather than only the saved state.

Inherit hint. For a repository the hint now reflects global -> org instead
of global alone, via a new name-keyed GET
/api/organizations/mirror-overrides that reuses
loadOrganizationMirrorOverrides. Personal repos skip the fetch, a failed
fetch degrades to the global values rather than blocking the dialog, and
the hint is suppressed while in flight so it is never briefly wrong.

Widens the UI to mirrorLabels and mirrorMilestones. mirrorMetadata stays
out: no mirror path reads it, so a per-object override would resolve
correctly and then do nothing. See the report for that finding.

useGiteaConfig additionally returns advancedOptions so callers can read
starredCodeOnly without a second request.

* fix(ui): read inherited mirror flags from mirrorOptions, not giteaConfig

/api/config does not return the mirror flags on giteaConfig. On the way
out, mapDbToUiConfig reshapes them into a separate mirrorOptions object
using different names (mirrorLFS, not lfs) and nesting the metadata flags
under metadataComponents with short names (issues, not mirrorIssues).

The dialog was written against the DB shape, so every lookup returned
undefined. Coerced to false, that is indistinguishable from a real "off",
which produced two symptoms: every inherit hint read "currently off"
regardless of the actual global config, and the labels gate never fired
because the effective issues value it keys on came from the same dead
source.

Adds mirrorOptionsToFlags() as the single conversion point, matching the
derivation in mapUiToDbConfig exactly, including that mirrorMetadata is a
master switch over the metadata components while lfs and mirrorReleases
sit outside it. Both dialogs now go through it, and useGiteaConfig returns
mirrorOptions alongside advancedOptions.

Server-side mirroring was never affected: the resolver reads config
straight from the DB, where the flags really do live on giteaConfig.

The existing tests could not catch this because they build a synthetic
config already in DB shape, so a client/server mismatch is invisible to
them. The new tests push flags through the real config-mapper in both
directions and assert the derived flags equal what mapUiToDbConfig would
persist, so they fail if that mapping changes again. One test pins the
root cause directly: that giteaConfig in the API payload carries no flags.

* fix(ui): surface the mirror-options filter on desktop, and on organizations

The overrides filter only existed inside the mobile filter drawer, so on
desktop the Repositories page showed a "Custom" badge on overridden rows
with no way to filter to them. Being able to answer "which repos deviate
from my defaults" on this page is the reason the Configuration-page
listing was dropped, so desktop could not do the one job it was given.

Adds the control to the desktop filter row next to status and sort, using
the bare Select-with-placeholder style those use rather than the drawer's
labelled markup. The mobile control is unchanged.

The Organizations page had a wider version of the same gap: it renders the
"Custom options" badge but never had this filter on either layout, and
OrganizationsList did not filter on it at all. Added the predicate plus
the control in both its layouts, so the two pages behave the same.

Also folds hasOverrides into activeFilterCount on both pages and into the
Organizations clear-all reset. Without that the mobile filter badge
undercounted an active overrides filter, and clearing filters left it set.

* fix(ui): stop double-applying the mirrorMetadata switch when reading config

mirrorOptionsToFlags ANDed each metadata component with mirrorMetadata on
the way in. That is a write-path rule: mapUiToDbConfig already applies it
when persisting, so a config saved through the settings UI has it baked
into the stored flag. Applying it again on read double-applies it.

The read path does not need it. mapDbToUiConfig puts the raw stored values
into metadataComponents, so metadataComponents.issues is exactly
giteaConfig.mirrorIssues, which is the field gitea.ts and
gitea-enhanced.ts read. Mapping the components straight through is 1:1
with runtime behavior.

Double-applying was invisible while the stored state was self-consistent,
since false && false is still false. It diverged when the state did not
come from the UI write path, which env vars allow: MIRROR_METADATA=false
with MIRROR_ISSUES=true stores mirrorMetadata:false, mirrorIssues:true.
The runtime mirrors issues; the dialog reported off. Measured against a
config in that state, five flags misreported.

I previously described this as an unrecoverable loss in the API shape.
That was wrong. Both values reach the client separately and unmerged, so
the payload carries full information and the loss was one we introduced.

Rewrites the test that asserted the derived flags match what
mapUiToDbConfig would persist. Comparing against the write derivation is
what pinned the bug in place. It now asserts the property that matters,
that flags derived from the API payload equal the stored flags the runtime
reads, plus a case checking agreement with resolveMirrorOptions on an
inconsistent config. Both fail if the AND returns.
2026-08-20 06:03:19 +05:30
Arunavo Ray a295a73066 fix(deps): patch nanoid, js-yaml and postcss in the www lockfile
Clears three Dependabot alerts on the marketing site:

  nanoid   3.3.16 -> 3.3.18  (infinite loop when size is zero)
  js-yaml  4.3.0  -> 4.3.1   (quadratic CPU on !!omap resolution)
  postcss  8.5.19 -> 8.5.26  (attacker-controlled source parsing)

All three are transitive and moved within their existing ranges, so the
change is version and integrity hashes only with no graph churn.
Verified with a frozen-lockfile install and a full site build.
2026-08-17 14:16:02 +05:30
Arunavo Ray 67cd0e2c60 docs: restore star history chart with scoped access token
GitHub restricted the stargazers API to repo admins and collaborators,
which broke the unauthenticated chart. Star History now needs a token
sealed into the image URL.

Uses a fine-grained token limited to this repository instead of a
classic public_repo token. The stargazers endpoint requires Contents
write, and classic public_repo grants that across every public repo the
account can push to, so scoping it to one repo keeps the blast radius
small. Legend moved to top-left to sit clear of the curve.
2026-08-17 14:11:31 +05:30
ARUNAVO RAY c9edc2132b Update star history links and image sources 2026-08-16 21:19:36 +05:30
Arunavo Ray d4db47c76e chore: bump version to 3.26.2 2026-08-07 17:03:41 +05:30
Arunavo Ray 91eb195494 fix(ui): smooth marquee scrolling and widen its hover target
Animating text-indent forced a relayout every frame, which made the
marquee visibly choppy. The scroll is now a compositor-driven transform.
The content stays plain inline text while at rest so the native ellipsis
still renders, and swaps to an inline-block only for the duration of the
animation.

The hover target is now the whole repository cell instead of just the
text, via a shared MarqueeTrigger wrapper. Name and path scroll together
at the same speed, so the motion reads as one block.
2026-08-07 13:38:47 +05:30
Arunavo Ray e0c39b697f chore: bump version to 3.26.1 2026-08-07 13:23:51 +05:30
Arunavo Ray 03fce1e299 fix(ui): keep repo name and path on one line with hover marquee
Long repository names and owner/repo paths in the repositories table
wrapped to multiple lines, breaking row alignment. Both now truncate
with an ellipsis and scroll horizontally on hover to reveal the hidden
part.

The new MarqueeText component animates text-indent instead of a
transform because ellipsis rendering only works on inline content, and
a transform would require an inline-block wrapper.
2026-08-07 13:23:48 +05:30
Arunavo Ray 7fc53cfad8 Revert "fix(security): upgrade better-auth family to 1.7.0-rc.4"
This reverts commit 2f6af22e25.
2026-08-06 07:19:13 +05:30
Arunavo Ray b33e7d5596 fix(security): patch image CVEs and unbreak Scout SARIF uploads
- Build git-lfs with Go 1.25.12 (stdlib CVEs fixed in 1.25.10) and pull
  golang.org/x/net past the fix for CVE-2026-39821; x/net 0.54.0 was
  flagged critical in the image scan.
- Cap SARIF relatedLocations at 100 per result before upload. GitHub
  rejects SARIF with >1000 related locations per result, and Docker
  Scout exceeds that for common OS packages, so every upload since May
  had failed silently (continue-on-error) and the Security tab was
  frozen on a stale scan. With uploads flowing again, already-fixed
  alerts (samlify, libgnutls via the existing apt-get upgrade) close on
  the next scan.
2026-08-06 07:00:03 +05:30
Arunavo Ray 2f6af22e25 fix(security): upgrade better-auth family to 1.7.0-rc.4
Fixes the @better-auth/oauth-provider advisory (unbound resource
indicators could yield access tokens for unauthorized audiences,
Dependabot #55). No patched 1.6.x exists; 1.7.0-rc.4 is the first
patched line.

The 1.7 oauth-provider expects a wider schema: new nullable columns on
oauth_clients / oauth_access_tokens / oauth_refresh_tokens /
oauth_consents (back-channel logout, DPoP, resource indicators, refresh
token rotation) and three new tables (oauth_resources,
oauth_client_resources, oauth_client_assertions). Migration 0014 is
purely additive; validate-migrations gains the matching upgrade fixture.
2026-08-06 07:00:03 +05:30
Arunavo Ray 185946984b chore: bump version to 3.26.0 2026-08-06 06:43:47 +05:30
Arunavo Ray bcc9cf0120 feat(github): cap ETag cache by size and scope tokenless clients
Follow-up to #356. The conditional-request store was bounded only by
entry count; a single 100-PR page can run to a few hundred KB, so 5000
entries could grow to gigabytes. The store now also tracks approximate
body bytes (64MB budget by default) and evicts oldest-first until both
caps hold. A body larger than the whole budget is not cached at all.

Clients created with only a token (the metadata mirroring path) all
shared the "default" cache scope across users. They now fall back to a
SHA-256 hash of the token, so distinct tokens never share entries and
the raw token never appears in cache keys.
2026-08-06 06:43:40 +05:30
Josh FreeandCopilot 2b455354f9 feat(github): reuse ETags across syncs via conditional requests (#356)
* feat(github): reuse ETags across syncs via conditional requests

The mirror re-lists every repository's pull requests on each scheduled
sync with `state: "all"`, and the Octokit client was created with the
throttling plugin but no conditional-request/ETag layer. Every sync
therefore re-downloaded unchanged data as full 200s and spent full
rate-limit budget.

Add an ETag cache wired into `createGitHubClient`: for each GET it
replays the previously stored `If-None-Match`, so GitHub returns
`304 Not Modified` (which does not count against the token's primary
rate limit) when nothing changed, and the cached body is reused. The
store is process-lifetime and scoped per user so ETags survive the
per-sync client re-creation without leaking data across tokens. Non-GET
requests are untouched.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* test(github): cover conditional-request ETag cache

Add unit tests driving a real Octokit instance with a stubbed fetch:
the second GET replays `If-None-Match`, a `304` is transparently served
from cache as a 200 with the same body, responses without an ETag are
re-fetched, non-GET requests are never made conditional, and cache
entries are isolated by scope.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* fix: key conditional-request cache by expanded URL

The request hook built the cache key from requestOptions.url, which is
still the route template (/repos/{owner}/{repo}/pulls) at hook time.
Every repo therefore shared one entry per user + endpoint, so with more
than one repo per user the stored ETag never matched and the 304 path
stopped firing. Expand the route via octokit.request.endpoint.parse
before building the key, and add a two-repo regression test that fails
on the shared-key behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
2026-08-06 06:36:31 +05:30
Arunavo Ray 428c97441c chore: bump version to 3.25.0 2026-08-03 11:59:44 +05:30
Arunavo Ray edf9c14be0 fix: honor DATABASE_URL for the SQLite database location
The variable was defined and documented but never read; the database
path was hardcoded to <cwd>/data/gitea-mirror.db. It now accepts
sqlite://, the legacy file: scheme, or a plain path, with relative
paths resolving against the working directory and the parent directory
created on demand. Defaults are unchanged, including the compose files
that pass the historical default value through. Docs updated to match.
2026-08-03 11:59:44 +05:30
ARUNAVO RAY 5c49191d61 Move canonical docs to the website (#354)
* docs: move canonical documentation to the website

The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.

This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.

The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.

* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
2026-08-03 11:56:51 +05:30
Arunavo Ray 63a4c9359a chore: bump version to 3.24.0 2026-08-03 11:29:01 +05:30
ARUNAVO RAY 882e147504 Redesign dashboard and configuration screens (#353)
* feat(ui): redesign dashboard and configuration screens

New settings design language built from the design/giteamirror.pen file:
cards with icon headers and status footers, header-level enable switches,
toggle switches instead of checkboxes, uppercase section titles, selection
tiles with icon chips and a check on the active option, segmented controls,
and an indigo accent. Implemented via shared primitives in
src/components/config/settings-ui.tsx and applied across:

- Automation: header switches, schedule card, one-line auto-mirror copy
  with info tooltip, full-width Repository Cleanup card with Skip/Archive/
  Delete tiles and dry run row
- Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook),
  events card with per-event switches
- Connections: GitHub/Gitea connection cards with token creation guide and
  field helpers, Repository Selection and Mirror Content cards covering
  every mirror option, Organization Structure card with strategy tiles,
  destructive update protection tiles (BETA label removed)
- Authentication: sign-in methods status card, identity providers restyle
- Dashboard: flatter stat cards, icon panel headers, indigo view-all links

All existing state handling, autosave and API behavior is unchanged.
Light mode keeps working via theme tokens. README and website screenshots
regenerated, docs references to renamed cards updated.

* fix(ui): design polish pass from local review

- Recent Activity rows get status icon circles (check, sync, sparkles, alert)
- Connections tab restructured: connection cards share a stretched grid row
  so GitHub and Gitea stay equal height; Mirror Content moved to the right
  column; forms split into placeable cards via a part prop
- Token guide panel: link moved to header as icon, larger text; redundant
  card footers removed (scopes line, test-connection hint)
- Repository Selection gains a footer note; retention explanation moved
  below the selector
- Authentication tab matches the design: side-by-side cards, row dividers,
  disabled state-reflecting switches with info hints, footers; SSO dialog
  restyled (segmented protocol tabs, field labels, indigo primary)
- Import GitHub Data button is the indigo primary; disabled state is muted
- Time format menu redesigned (locale pill, live examples, live clock in
  the trigger); theme switcher moved to sidebar as icon segmented control,
  system preference now persists correctly; legacy ModeToggle removed
- Automation timezone pill no longer shows stored legacy UTC as a choice
- Config tab bar wraps 2x2 on narrow screens instead of overflowing
- README, website and PR screenshots regenerated
2026-08-03 11:27:45 +05:30
Arunavo Ray da75f10f26 chore: bump version to 3.23.0 2026-08-03 08:06:31 +05:30
Arunavo Ray e7758badbf feat: add generic webhook notification provider (#352)
Adds Webhook alongside ntfy, Apprise and Gotify: posts a JSON payload
(title, message, type, timestamp) to any URL, with an optional signing
secret that adds an X-Webhook-Signature header (HMAC-SHA256 of the body,
sha256=<hex>) so receivers can verify authenticity. Secret is encrypted
at rest like the other provider tokens. Settings UI section, provider
and service tests included. No database migration needed.
2026-08-03 08:06:26 +05:30
Arunavo Ray 69198c047a chore: bump version to 3.22.0 2026-08-01 10:08:47 +05:30
Arunavo Ray 4f55cb406c ci(nix): make the bun.nix drift check blocking
The check added in #350 only echoed a message (with backticks that bash
executed as command substitution, mangling it) and never failed the job,
so a stale bun.nix would still pass CI. Fail fast right after the
regenerate step instead, with a GitHub error annotation and the diffstat.
2026-08-01 10:08:31 +05:30
Jordan Dominion c7932faecb fix(nix): regenerate bun.nix from bun.lock (#350)
bun.nix (last regenerated in #298) drifted from bun.lock after the dependency updates in #348, leaving the Nix flake build broken: 254 packages missing and 195 stale entries. Regenerated with bun2nix; all 929 entries now match bun.lock exactly.

Also adds a CI step that detects drift after the regenerate step (made blocking in a follow-up commit).

Thanks @Cyberboss.
2026-08-01 10:07:51 +05:30
Arunavo Ray e40eaabf4c docs(www): fix inaccurate claims in use-case and comparison pages
- Remove references to nonexistent /api/export and /api/repos/:id/logs endpoints
- Correct storage model: mirrored repos and LFS live in Gitea, not the data/ volume
- Fix default sync interval (daily, not 1 hour) and startup log line
- Add write:organization to required Gitea token scopes
- Remove nonexistent metrics endpoint and per-repo interval claims from Helm page
- Fix rate-limit advice (limits are per account, not per IP/token)
- Refresh stale comparison content (outage dates, BackHub/Rewind acquisition)
- Add missing git clone step to comparison quick start
2026-07-29 08:26:08 +05:30
云与原 204922570d feat: add Gotify as a notification provider (#337)
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
2026-07-16 22:20:15 +05:30
Arunavo Ray 97d98b82c6 chore: bump version to 3.21.0 2026-07-16 21:52:01 +05:30
ARUNAVO RAY 8b81ffa975 chore(deps): security fixes and dependency updates across app and www (#348)
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
2026-07-16 21:51:41 +05:30
Arunavo Ray bb57b52de3 test: isolate bulk-mirror destination tests in a child process
bun's mock.module and the globalThis.fetch swap are process-wide; on CI
(bun 1.3.13) this file's mocks of @/lib/db and @/lib/gitea-enhanced leaked
into gitea-enhanced.test.ts and stuck-status-recovery tests, failing main.
The file now registers nothing in the shared test process and instead
re-runs itself via bun test in a child process where the mocks are contained.
2026-07-16 21:46:08 +05:30
Arunavo Ray b3aad9d80f test: make org ids order-independent in bulk-mirror destination tests
The previous call-order counter diverged between the mocked flow and the
assertions when bun re-instantiates mock factories (green on bun 1.3.6
locally, red on 1.3.13 in CI). Ids are now a pure function of the org name.
2026-07-16 21:35:56 +05:30
Arunavo Ray 3b8625634c docs: add time format toggle screenshot 2026-07-16 21:02:21 +05:30
ARUNAVO RAY bdbfa762af feat(ui): add 12h/24h time format option with locale-aware default (#342) (#346)
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage.

Fixes #342
2026-07-16 20:57:48 +05:30
ARUNAVO RAY f922bcc618 fix: recover repositories stuck in syncing/mirroring after crashes (#339) (#347)
Adds stuck-status recovery: repositories (and orgs) stranded in an in-flight status by a crash/restart are reset to failed with an explanation, on container start and every scheduler tick, guarded by the existing 2h liveness window.

Fixes #339
2026-07-16 20:57:38 +05:30
ARUNAVO RAY c5b331c041 fix: stop config saves from resetting env-configured GITEA_MIRROR_INTERVAL (#338) (#345)
Config saves now preserve every field the settings form doesn't expose (mirror interval and other env-only options) instead of resetting them to defaults.

Fixes #338
2026-07-16 20:57:29 +05:30
Arunavo Ray 5c33a5547b test: cover bulk org mirror destination routing (#343) + clarify mixed-strategy log
- Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to
  the migrate HTTP payload: org-level override, per-repo override, mixed
  strategy uid, starred-repo mode, and preserve/single-org/flat-user
  no-override regression paths. All four bug-scenario tests fail on main
  and pass with PR #344 applied.
- Fix the top-level log that claimed 'flat-user strategy' when the mixed
  strategy falls into the same branch.
2026-07-16 20:55:15 +05:30
Yuzu 537aae952d fix: honor destination overrides in bulk org mirroring and crash recovery (#343) (#344)
Routes the bulk Mirror Organization path and crash recovery through the canonical destination resolver (getGiteaRepoOwnerAsync), so org-level and per-repo destination overrides are honored, the mixed strategy no longer sends org repos to the user's personal account (previously uid was dropped from the migrate payload and Gitea defaulted to the authenticated user), and starred repos follow starred-repo mode even when swept up in a bulk org mirror.

Fixes #343
2026-07-16 20:54:58 +05:30
Arunavo Ray 40efb9b83a chore(www): point site URLs to gitea-mirror.raylabs.io
The old giteamirror.com domain is being retired to avoid paying for
per-project domains. Repoint all canonical URLs, og:url, the homepage
siteUrl, robots.txt sitemap ref, and sitemap.xml loc from the old
gitea-mirror.com domain to the new gitea-mirror.raylabs.io site.
2026-07-06 00:40:27 +05:30
Arunavo Ray 06bfb49e0e chore: bump version to 3.20.4 2026-07-02 15:46:20 +05:30
ARUNAVO RAY b5e0c58708 fix: stop false-positive orphan archiving and heal sync 405s on archived-* renamed mirrors (#331) (#336)
Three related fixes for the "repos keep getting archived and then fail to
sync with HTTP 405" report:

1. Orphan cleanup no longer archives on bulk-list absence alone.
   Repos added via the "+" Add Repository dialog (foreign owner, not
   starred) can never appear in the authenticated bulk fetches, so every
   cleanup cycle deterministically flagged them as orphaned and archived
   them. identifyOrphanedRepositories() now runs a targeted per-repo
   confirmation (starred check or repos.get) and only treats a clean 404
   as gone; any other outcome fails safe.

2. The archived-* rename is persisted. archiveGiteaRepo() now returns
   the actual post-rename name and the cleanup service records it in
   mirroredLocation, so the DB no longer points at a name that only
   301-redirects.

3. Sync self-heals repos renamed in Gitea/Forgejo. Requests to a
   renamed repo get a 301; fetch follows it, downgrading POST to GET,
   which lands on the POST-only mirror-sync endpoint as a 405. The sync
   candidate loop now adopts the canonical owner/name from the GET
   response body before POSTing, tries an archived-{name} fallback for
   archived repos (guarded by an original_url source match), and keeps
   archived repos archived: no mirror-interval PATCH, status stays
   'archived' per the documented Manual Sync contract.

Also hardens mirrorGitHubReleasesToGitea to derive GitHub coordinates
from fullName so Gitea-side names can never leak into GitHub API calls.

Verified end-to-end on Forgejo 15.0.3 (rootless): pre-fix reproduces the
exact 405; post-fix the stale-name sync succeeds (GET stale -> 301, GET
canonical -> 200, POST canonical mirror-sync -> 200), archived repos keep
interval "0s" with no PATCH issued, and non-archived renamed repos heal
and get the configured interval applied.
2026-07-02 15:46:00 +05:30
Arunavo Ray 74606f0a5f chore: bump version to 3.20.3 2026-07-01 08:13:15 +05:30
ARUNAVO RAY 187ecc5d60 fix: correctly mirror Gitea release titles and issue/PR labels (#334 + sibling) (#335)
* fix(releases): send Gitea release title as `name`, not `title` (#334)

Gitea/Forgejo expose the release title through the JSON field `name`
(the API Go struct is `Title string `+"`"+`json:"name"`+"`"+`). The release
create and update payloads sent `title:` instead, which Gitea silently
ignores, so every mirrored release landed with a blank title.

Verified live against Gitea 1.24.7: a POST/PATCH with `title` yields
`name: ""`; the same call with `name` sets the title correctly. The
update path also self-heals previously-mirrored releases whose names were
left blank, since the existing-vs-expected name comparison already drives
a PATCH.

Adds gita-release-name.test.ts, which drives the real
mirrorGitHubReleasesToGitea create/update paths with a mocked fetch and
asserts the payload carries `name` (and never `title`).

* fix(issues): reconcile labels on issue/PR update via the labels sub-resource (#334 sibling)

Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
`CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
body is silently dropped — the same silent-ignore class as the release
`title` vs `name` bug. The issue and PR-as-issue update paths sent `labels`
in the PATCH body, so label changes never propagated onto already-mirrored
issues (and a deadlock-orphaned issue recovered via PATCH never got its
labels).

Fix: add `reconcileGiteaIssueLabels`, which replaces the label set via
`PUT .../issues/{index}/labels` (idempotent — adds new, removes deleted).
Call it on the two issue update paths and the two PR-issue update paths,
and drop the dead `labels` key from those PATCH bodies. Labels on freshly
created issues still come from CreateIssueOption on the POST.

Verified live against Gitea 1.24.7 (PATCH ignores labels; PUT applies them)
and end-to-end (a drifted mirrored issue reconciled from no-labels to its
GitHub label set). Adds gitea-issue-labels.test.ts driving the real
mirrorGitRepoIssuesToGitea update path; the test carries a self-contained
http-client mock so it is immune to another suite's global module mock.

* test: make #334 regression tests deterministic via pure payload builders

The prior tests drove the real mirror functions with a global `fetch` mock.
That is order/version-fragile: another suite installs a process-global
`mock.module("@/lib/http-client")`, and bun 1.3.13 (CI) runs test files
concurrently, so `globalThis.fetch` races across files and
`isRepoPresentInGitea` (raw fetch) intermittently sees the wrong mock —
green locally on bun 1.3.6, red in CI.

Extract the payload construction into pure, exported builders and assert on
those instead (the repo's existing `classify*` pattern): buildGiteaReleasePayload
(create+update send `name`, never `title`), buildGiteaIssueEditPayload (edit
body never carries `labels`), buildGiteaIssueLabelsPayload (labels sub-resource
body). Behavior is unchanged — the builders return the exact same objects the
call sites built inline — and the fixes remain verified live on Gitea 1.24.7.
2026-07-01 08:12:36 +05:30
Arunavo Ray 632bbd0d4a chore: bump version to 3.20.2 2026-06-24 17:19:10 +05:30
ARUNAVO RAY 0b65e40784 fix(releases): create releases only for tags present in Gitea; stop sending target (#331) (#333)
Release creation failed on some Gitea/Forgejo instances with
"HTTP 404: The target couldn't be found", so no release (and therefore no
assets) was ever created — re-syncing never recovered.

Root cause: the create payload always sent `target: target_commitish`
(e.g. "main"). When the release's git tag is not yet present in the Gitea
mirror — which happens when Gitea's own git mirror clone lags behind the
metadata sync — Gitea tries to *create* the tag from `target`; if that ref
can't be resolved it returns a generic 404 ("The target couldn't be
found"), and if it can, it would create a brand-new tag at the wrong commit.

Reproduced the reporter's exact stack (Forgejo 15 rootless + read_only +
cap_drop ALL + postgres, plus Gitea 1.20-1.26 and Forgejo 1.21-15): a
healthy repo always succeeds — the 404 only occurs when the tag is absent
at create time.

Fix:
- Before creating a release, verify the git tag already exists in Gitea.
  If it isn't synced yet, skip it (logged) and let a later sync create it
  once the mirror has the tag — never create a tag via `target`.
- Drop the `target` field from both the create and update payloads. For a
  mirror the tag is synced from upstream, so Gitea attaches the release to
  the existing tag; `target` is unnecessary and is what triggers the 404.
- Surface skipped-missing-tag releases in the summary log for diagnosability.

Verified end-to-end against the real mirror function on a Forgejo instance:
a release whose tag exists is created with its assets; a release whose tag
was removed is skipped cleanly (no 404, no bogus tag) and picked up once the
tag is present.
2026-06-24 17:18:46 +05:30
Arunavo Ray 4a8b4f6ff3 chore: bump version to 3.20.1 2026-06-23 23:07:16 +05:30
ARUNAVO RAY 1d9dfdeb70 fix(releases): mirror assets idempotently so missing assets self-heal (#331) (#332)
Release assets were uploaded only on the create path of
mirrorGitHubReleasesToGitea(). When a Gitea release already existed, the
update path PATCHed the changelog/title and `continue`d without ever
touching assets. So any release whose assets were not fully uploaded on
that single create-path run — first sync interrupted, a transient
download/upload failure, large multi-MB assets, etc. — stayed permanently
asset-less, and re-syncing always hit the update path and could never
recover it. Asset failures were also swallowed to console.error, so the
job still reported success (the "no errors in the logs" in #331).

Reproduced on a real Forgejo pull-mirror with shauninman/MinUI: a GitHub
release with two ~35-40MB binaries became a Gitea release with 0 assets
(just Forgejo's auto-generated source archive), and re-syncing left it at
0 while logging "Updating existing release".

Fix:
- Add reconcileReleaseAssets(), an idempotent reconciler run on BOTH the
  create and update paths. It compares Gitea's existing attachments to
  GitHub's by name+size: skips matches, uploads missing ones, replaces
  size-mismatched copies. Existing broken releases self-heal on next sync.
- Extract the pure decision into classifyAssetsForReconciliation() for
  unit testing (the absence of asset tests is why this slipped past #310).
- Surface asset upload counts in the summary and emit a visible warning
  when any fail, instead of silently swallowing them.
- Add 5 unit tests covering the broken state, partial backfill,
  idempotency, size-mismatch replacement, and the no-assets case.

Verified end-to-end against the real function on a Forgejo pull-mirror:
0 -> 2 assets backfilled, already-present assets skipped (no re-download),
second run uploads 0.
2026-06-23 23:06:43 +05:30
Arunavo Ray 079df29f44 chore: bump version to 3.20.0 2026-06-19 08:44:03 +05:30
ARUNAVO RAY dff3cafb5e fix(config): persist Name Collision Strategy (starredDuplicateStrategy) (#326) (#328)
The "Name collision strategy" dropdown (starredDuplicateStrategy) never
persisted: the field was absent from both directions of the UI<->DB config
mapper. On save, mapUiToDbConfig dropped it before the DB write; on load,
mapDbToUiConfig never read it, so the UI reset to the "suffix" (repo-owner)
default. Mirror logic in gitea.ts then read undefined and also defaulted to
suffix — so repos really were created with that pattern regardless of the
user's choice. It has been broken since the field was introduced.

- Map starredDuplicateStrategy in mapUiToDbConfig and mapDbToUiConfig
- Add STARRED_DUPLICATE_STRATEGY env var for parity (reporter could not
  work around it via compose because no env var existed) + docs
- Round-trip tests covering save, load, and the missing-field default
2026-06-19 08:43:27 +05:30
ARUNAVO RAY 6ca7c0eec0 feat(github): add organization allowlist to mirror only selected orgs (#327)
Repository discovery requested the `organization_member` affiliation
unconditionally, so repos from every org a user belongs to were imported —
even orgs they never explicitly added. `skipPersonalRepos` only dropped
user-owned repos and left org repos unfiltered, which surprised users who
expected "only mirror org repos" to mean "only the orgs I chose" (reported
on #304).

Wire up the previously-dormant `includeOrganizations` config field as an
opt-in allowlist: when non-empty, only repos owned by the listed
organizations are imported. Empty = all org repos (backward-compatible).
Owned and collaborator repos are never restricted, so it composes cleanly
with `skipPersonalRepos`.

- Filter org repos by the allowlist in getGithubRepositories
- Add includeAllOrgsOverride so the cleanup service bypasses the allowlist
  and never false-orphans a previously-mirrored repo from an org the user
  later removes from the list
- UI control under Filtering & Behavior; INCLUDE_ORGANIZATIONS env var
- Case-insensitive dedup/trim in the UI<->DB mapper round-trip
- 7 unit tests covering the filter, composition, and the cleanup override
2026-06-19 08:42:17 +05:30
ARUNAVO RAY 6ebf1916e8 chore(deps): update dependencies across app and website (#325)
* chore(deps): update app dependencies to latest in-range versions

* chore(deps): update website (www) dependencies to latest in-range versions
2026-06-14 12:25:45 +05:30
Arunavo Ray 91de0d1030 chore: bump version to 3.19.1 2026-06-14 12:07:48 +05:30
Brendan Davidson 85bd1f4042 Repository table bulk actions (#322)
* Handle indexing when shift + clicking in the repository table

* Move the buttons when selecting rows

* Add in a bulk delete func in the repositories table

* Add bulk delete handler

* Make the single action use the bulk delete

* Delete the single repository id handler
2026-06-14 10:14:51 +05:30
Brendan Davidson 4a28015685 Skip the user defined orgs to ignore (#323) 2026-06-14 10:14:48 +05:30
Arunavo Ray da23941369 chore: bump version to 3.19.0 2026-06-13 09:14:33 +05:30
Brendan Davidson 906ce57e8c Handle indexing when shift + clicking in the repository table (#316) 2026-06-13 09:14:02 +05:30
Arunavo Ray 1b84c75a97 chore: bump version to 3.18.0 2026-06-13 08:17:20 +05:30
ARUNAVO RAY 0b6b6b76bf feat(github): add skipPersonalRepos toggle to mirror only org repos (#304) (#320)
- Add `skipPersonalRepos: z.boolean().default(false)` to githubConfigSchema
- Filter out user-owned repos in getGithubRepositories when flag is true
- Wire ONLY_MIRROR_ORGS env var to skipPersonalRepos in env-config-loader
- Add checkbox UI in GitHubMirrorSettings Filtering & Behavior section
- Round-trip skipPersonalRepos through config-mapper (UI ↔ DB)
- Add skipPersonalRepos to AdvancedOptions TypeScript type
- Mark include/exclude arrays in configSchema as unused/reserved
- Update ENVIRONMENT_VARIABLES.md to document ONLY_MIRROR_ORGS effect
2026-06-13 08:00:50 +05:30
ARUNAVO RAY 7610a614da fix: scheduler auto-start gate, backup clone URL, cancel-pending action, actionable 405 (#319)
* fix(scheduler): make enabled flag authoritative for auto-start

checkAutoStartConfiguration() and performInitialAutoStart() previously
used `scheduleEnabled || hasMirrorInterval`, allowing a configured
GITEA_MIRROR_INTERVAL to trigger boot-time auto-start even after the
user disabled scheduling via the UI toggle.

env-config-loader already writes scheduleConfig.enabled=true when
GITEA_MIRROR_INTERVAL is set at container startup, so the interval is
a timing detail, not an enable signal. The documented env-var contract
is preserved: GITEA_MIRROR_INTERVAL at boot → env-config-loader sets
enabled=true → auto-start fires. But a later UI disable now sticks.

Add a focused unit test for the gate logic.

* fix(backup): always derive clone URL from user-configured Gitea URL

The pre-sync backup preferred repoInfo.clone_url, which reflects
Gitea's ROOT_URL setting. In Tailscale MagicDNS deployments (and any
setup where ROOT_URL is an external address), this URL is unreachable
from the app itself, causing bundle backup to fail.

Always build the clone URL as:
  ${config.giteaConfig.url.trimEnd('/')}/${owner}/${repo}.git

This matches the URL the app already uses for all other Gitea API
calls and is guaranteed reachable.

* feat(jobs): cancel-pending endpoint + fix misleading Delete All copy

Add POST /api/job/cancel-pending that sets the current user's
repositories with status "imported" or "failed" to "ignored",
preventing the scheduler from re-queuing them. In-flight "mirroring"
rows are left alone. Returns the count and logs one activity entry.

Fix the "Delete All Activities" dialog to clearly state it only clears
the history log and does not stop pending work. Rename button/title to
"Clear History" so intent is unambiguous.

Add a "Stop Pending Mirrors" button (StopCircle icon, amber) in both
mobile and desktop activity log toolbars, with a confirmation dialog
explaining repos are set to Ignored and can be re-enabled from the
Repositories page.

* fix(sync): actionable 405 error for non-pull-mirror repos

Gitea returns HTTP 405 with an empty body when the target repository is
no longer a pull mirror — e.g. the mirror was auto-disabled by Gitea or
the repository lost its mirror state after a manual edit.

Previously this fell through to the generic error handler which stored
the raw HttpError message (often empty) giving the user no guidance.

Now a 405 response is caught alongside the existing 400 handler and
sets the repository to "failed" with an actionable error message:

  "Gitea reports this repository is not a pull mirror (HTTP 405).
  In Gitea check Settings → Mirror Settings; if the mirror section is
  missing, delete the repository in Gitea and re-mirror it from
  gitea-mirror."

The same message is written to the activity log for visibility in the
dashboard.
2026-06-13 08:00:47 +05:30
ARUNAVO RAY c28dcc209f fix(releases): stop delete/recreate cycle on permanent order mismatch (#310) (#318)
Root cause (Theory A): the `needsRecreation` check compared GitHub
published_at-based expected indices against Gitea's API order. Gitea mirror
repos sort releases by tag-commit date, which can permanently disagree with
published_at order (e.g. unaconfig_dart v0.1.0 published after v0.1.1 but
tagged before). This made `currentExpectedIdx < nextExpectedIdx` evaluate
true on every sync, triggering delete-all-and-recreate forever — spamming
Gitea's activity feed with "released X" events (#310).

Fix: replace the destructive order-check machinery with set-based
reconciliation via `classifyReleasesForReconciliation`. Releases are
created when missing in Gitea and skipped (or PATCH-updated if content
drifted) when already present. No deletions are ever triggered by ordering.
Retain the existing release-limit trimming (retention cleanup) unchanged.

Also removes the 1-second per-release delay that was only needed for the
creation-order dance, significantly speeding up initial mirrors.

Adds unit tests covering: normal ordered repos, the unaconfig_dart inversion
fixture, missing→create, present→skip, and edge cases.
2026-06-13 08:00:44 +05:30
ARUNAVO RAY 40ee3cbc44 fix(mirror): reuse existing same-source mirrors instead of creating suffixed duplicates (#315) (#317)
Starred (and other) repos duplicated on every re-mirror (starred/Repo,
Repo-owner, Repo-owner-1, ...) because the existence check only asked
"does a repo with this name exist?" and never "is the existing repo a
mirror of THIS same source?". The repo's own prior mirror counted as a
collision, so generateUniqueRepoName bumped to the next suffix each run,
repointing mirroredLocation at the newest copy. Under a single re-call,
3 concurrent/retried jobs each computed a DIFFERENT suffixed name, so the
location-based in-flight guard never matched and the race produced extra
copies.

Fix (source-identity aware):
- New shared helper src/lib/utils/mirror-source-match.ts:
  - normalizeCloneUrl / cloneUrlsMatch: credential-, .git-, slash- and
    host-case-insensitive clone URL comparison.
  - isMirrorOfSource: a Gitea repo is "ours" only if it is a mirror AND
    its original_url matches this repo's source.
  - findExistingMirror: resolves an existing same-source mirror via the
    recorded mirroredLocation first (survives strategy changes — #309),
    then the base candidate name.
  - classifyCandidateName: pure available/reusable/taken decision.
- gitea-enhanced: export GiteaRepoInfo and add original_url (Gitea's
  recorded migration source) for source matching.
- Both create paths (mirrorGithubRepoToGitea, mirrorGitHubRepoToGiteaOrg):
  run findExistingMirror BEFORE name generation; on a hit, reuse that
  location and route into the existing "already mirrored" handling rather
  than calling generateUniqueRepoName. Names now converge under
  concurrency so the in-flight guard becomes effective.
- generateUniqueRepoName is now source-aware: an occupied name held by a
  mirror of the SAME source is reused (no suffix); suffixing only happens
  on a genuine different-source collision, preserving #95/#236 behavior.
  The per-user DB claim check is retained so two users mirroring the same
  source into a shared org stay separated.
- Phantom-fork guard (#309): the existingRepoInfo.mirror branches now
  verify same-source before marking "mirrored"; on mismatch they fall
  through to unique-name generation and create a separate mirror.
- Scheduler: a `failed` repo whose mirroredLocation still resolves to a
  live same-source mirror is routed to syncGiteaRepo instead of re-create,
  breaking the failed-metadata re-create loop cheaply.
- Remove dead src/lib/starred-repos-handler.ts (zero importers across all
  git history); its correct base-name/.mirror reuse logic now lives in the
  shared helper.

Tests: src/lib/utils/mirror-source-match.test.ts (30 cases) covers URL
normalization, reuse at base name, reuse via mirroredLocation across a
strategy change, genuine different-source collision (suffix), phantom
fork, stale mirroredLocation fallback, per-user DB-claim separation, and
the suffix-vs-reuse classification. Full suite: 319 pass, 0 fail.
2026-06-13 08:00:41 +05:30
Arunavo Ray 699a5771f5 chore: bump version to 3.17.1 2026-06-05 18:42:11 +05:30
ARUNAVO RAY e862714d6a fix(db): self-heal sso_providers duplicate-column crash on upgrade (#312) (#313)
Migration 0013 runs as a single transaction that rebuilds `organizations`
and then `ALTER TABLE sso_providers ADD saml_config` / `ADD domain_verified`.
On instances where those columns were already created outside Drizzle (declared
in schema.ts and added via db:push / an SSO-register round-trip on an
intermediate build), the ADD throws "duplicate column name: saml_config". That
rolls back the entire 0013 transaction, so 0013 is never recorded in
`__drizzle_migrations` and is retried — failing identically — on every boot,
crash-looping the server.

Add a pre-migrate repair (mirroring the existing repairFailedMigrations() for
the 0009 case): when 0013 is unrecorded but the columns already exist, preserve
any real SAML provider config, drop the stranded columns so the canonical 0013
runs in full (organizations rebuild included), then restore the preserved
values once the columns are re-added. No-op on fresh installs, clean upgrades,
and already-migrated databases.

This lets affected instances recover automatically on the next boot after
upgrading — no manual SQLite surgery required.

- src/lib/db/migration-repairs.ts: repairDuplicateSsoColumns + restoreSsoDataAfter0013
- src/lib/db/index.ts: wire both around migrate()
- scripts/validate-migrations.ts: cover the broken-upgrade + data-preservation path
2026-06-05 18:41:46 +05:30
Arunavo Ray 716981aa04 chore: bump version to 3.17.0 2026-06-02 11:41:24 +05:30
ARUNAVO RAY 66e3284898 fix(sso): repair SSO login bounce + migrate to @better-auth/oauth-provider (#307)
Resolves #306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the
SSO identity to an existing email/password admin instead of bouncing to /login
with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied
**Domain** field — cross-domain claims from a compromised IdP are refused.

Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider`
migration. **Operators using the OAuth-provider feature must rotate registered
client secrets after upgrade** (legacy plaintext → hashed storage; see the
0012 migration notes).

Verified end-to-end on the pr-307 image against a real Authentik instance:
SSO login lands on the dashboard, `accounts` table gets both `credential` and
`authentik` rows for the same user. See PR description for full details.
2026-06-02 11:40:54 +05:30
IYUANWEIZE 695f0ff005 Fix Docker image line in .env.example (#305) 2026-05-28 13:49:32 +05:30
Arunavo Ray 53f2cf36fc chore: bump version to 3.16.3 2026-05-27 14:53:34 +05:30
ARUNAVO RAY 8ffcf3bdc6 fix: bridge header auth into a real Better Auth session (#303)
Header / forward authentication has been end-to-end broken since the
v3 rewrite. The middleware populated `context.locals.user` from
trusted upstream headers (Authentik / Authelia / oauth2-proxy /
Caddy), but never minted a Better Auth session, and never set a
cookie. Server-rendered pages saw the user, but the React SPA's
`/api/auth/get-session` call hit Better Auth's handler — which only
reads its session cookie — and got `null`. The auth guard then
redirected to `/login`, even though the upstream proxy had already
authenticated the user.

Reported on issue #29 by @lanrat with a clean repro on v3.16.1.

Fix: add a small Better Auth plugin (`header-auth`) that exposes
`POST /api/auth/sign-in/header`. The endpoint validates the trusted
headers via `authenticateWithHeaders`, creates a real session row via
`internalAdapter.createSession`, and attaches the `Set-Cookie` via
`setSessionCookie` — the same pattern the magic-link, anonymous, and
phone-number plugins use after their respective verification steps.

The Astro middleware now calls this endpoint when no cookie session
exists and header auth is enabled, forwards the `Set-Cookie` onto the
outbound response, and populates `context.locals` from the minted
session. After the first request the browser has the cookie; every
subsequent request takes the normal cookie-auth fast path and the
bridge doesn't fire.

Fail-open everywhere: any endpoint failure (header auth disabled,
auth rejected, DB blip, malformed response) returns null from the
bridge and the request proceeds as anonymous. A broken header-auth
configuration must never lock everyone out of the cookie-auth path.

Tests:
- `auth-header.test.ts` — unit tests for `extractUserFromHeaders` and
  `isHeaderAuthEnabled`, including lanrat's reported config shape
  (same header for username and email).
- `auth-header-plugin.test.ts` — locks down plugin id, endpoint key,
  path, and method so an accidental rename can't silently break the
  middleware bridge.
- `auth-header-bridge.test.ts` — covers the cookie-extraction logic
  and the fail-open paths (non-2xx, thrown error, malformed JSON,
  missing fields, no Set-Cookie attached).

Stacks on top of #301 (better-auth 1.6.11 bump).

Refs: #29
2026-05-27 14:53:16 +05:30
ARUNAVO RAY a07af96f84 chore: bump better-auth to 1.6.11 (#301)
Updates `better-auth` and `@better-auth/sso` from 1.5.5 to 1.6.11 to
pick up the patch fixes that have landed since (OAuth state CSRF
verification, scrypt non-blocking password hashing, account cookie
comparison fix, session freshness alignment, etc.). All 270 local
tests pass against the new version.

The only behavioral surface to watch:
  - `freshAge` now aligns with session `createdAt` instead of
    `updatedAt`. Not applicable here — we don't gate anything on
    session freshness.
  - 1.6.2 adds OAuth state-parameter CSRF verification. Applies to
    OIDC/SAML SSO flows; no code changes needed.
  - 1.6 emits a deprecation warning for `oidc-provider` in favor of
    `@better-auth/oauth-provider`. The plugin still works in 1.6.x;
    migrating it is a separate cleanup.

Prep work for an upcoming header-auth fix (issue #29 follow-up).
2026-05-27 14:44:17 +05:30
Arunavo Ray b1daa65228 chore: bump version to 3.16.2 2026-05-26 11:09:31 +05:30
Sean MousseauandClaude Opus 4.7 dd1c42264e fix: stop snapshot-row zombies + flapping force-push on deleted branches (#300)
* fix: stop snapshot-row zombies + flapping force-push on deleted branches

Two bugs caused Simple-WP-Helpdesk to accumulate one orphan
"Snapshot created" job row per scheduled sync — 7 zombies in 24h
against a deleted GitHub branch (`fix/v4.0.2-webhook-comment-author`).

(1) gitea-enhanced.ts:522 created the post-snapshot job record with
status="syncing". The snapshot was already complete at that point
(createPreSyncBundleBackup had returned), and no later code path
advanced the row to a terminal status — it just lingered. Set
status="synced" to reflect reality.

(2) force-push-detection treated any Gitea branch missing from GitHub
as a "deleted" force-push. But gitea-mirror is one-way (GitHub →
Gitea), so deletions never propagate back to the Gitea mirror —
the branch lingers in Gitea forever, the missing-from-GitHub condition
holds on every subsequent sync, and detection re-fires endlessly.
Each re-fire triggered backupBeforeSync=true to take a fresh snapshot
of the exact same state we already backed up the previous cycle.

Fix: detector accepts an `acknowledgedDeletions: { branch, giteaSha }[]`
list (persisted in RepositoryMetadataState). A Gitea branch missing
from GitHub is suppressed when its current giteaSha matches an entry.
If the giteaSha later changes (branch restored, then re-deleted with
new history), the entry won't match and detection fires again — back
up the new state, then acknowledge the new SHA. After a successful
snapshot, the call site appends each just-backed-up "deleted" branch
to the acknowledged list and persists the metadata blob.

Hoists parseRepositoryMetadataState above the backup-strategy block so
both the detection-time read and post-snapshot append happen against
the same in-memory state object; the existing metadata-mirror block
just stops re-declaring it.

Adds 9 new test cases covering:
  - deleted branch suppressed when acknowledged at matching giteaSha
  - re-flagged when giteaSha differs (restored-then-redeleted)
  - mixed deletions (only matching one suppressed)
  - undefined list = back-compat (existing callers unaffected)
  - acknowledgedDeletions does not suppress "diverged"
  - metadata-state parse/serialize round-trips the new field
  - legacy metadata defaults acknowledgedDeletions to []
  - malformed entries dropped without throwing

All 76 tests in the affected suites pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: acknowledge deletion before backup, not after, to fix concurrent-sync race

When two sync invocations fire for the same repo within the same backup
window (observed in prod via UI trigger pipeline: a single click ends up
producing two parallel processWithResilience callbacks that both run
syncGiteaRepoEnhanced), the second invocation's createPreSyncBundleBackup
short-circuits (file already exists from the first), so the second
invocation never reached the acknowledge-push that lived inside the
backup try block. Then both invocations wrote metadata: invocation A
with acknowledgedDeletions=[forged], invocation B (with its stale
in-memory state) with acknowledgedDeletions=[], and B's write landed
last — overwriting A.

Net result: the deletion never got acknowledged, and the next sync
re-detected it, the next-next sync re-detected it, etc. Same flapping
behavior as before the fix.

Fix: move the acknowledge-push to run right after detection, based on
detectionResult.affectedBranches alone. Semantically correct — once
we've detected the branch is gone from GitHub, we know it's a permanent
deletion; whether THIS particular invocation took a backup or
short-circuited is orthogonal (a previous backup on disk is fine, and
in the rare case where no backup ever succeeded, the user can re-trigger
a manual backup). Both concurrent invocations now push the same entry;
both write the same metadata; race is benign.

Verified locally: 59 tests pass across force-push-detection,
gitea-enhanced, repo-backup.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:06:27 +05:30
Arunavo Ray 384fbbbe10 docs: document Header / Forward Authentication setup
Header auth has been a working feature since v2.x but was missing from
SSO-OIDC-SETUP.md, leading users to think it was dropped in the v3
rewrite (see #29). Adds a dedicated section covering env-var config,
Authentik + Authelia examples, lookup order, verification, and the
must-strip-inbound-headers security checklist.
2026-05-25 10:37:39 +05:30
Sean Mousseau 1a54010950 fix: prevent duplicate milestones & labels on every sync (#299)
Paginate /milestones and /labels with Link header + X-Total-Count fallback, and pass state=all on milestones so closed milestones aren't re-POSTed on every sync. Caches newly-created items in the in-memory dedup set.
2026-05-25 08:28:40 +05:30
CrumblyLiquid 9bf22791a9 Regenerated bun.nix (#298) 2026-05-25 08:26:19 +05:30
Arunavo Ray 5d82f22b12 chore: sync version to 3.16.1 2026-05-23 20:25:16 +05:30
Sean Mousseau 6979c3bb32 fix: resume interrupted jobs after startup (not only at boot) (#297)
The previous middleware logic gated recovery behind a
once-per-process flag. After the first request handled the boot-time
recovery check, the resume codepath never fired again — even when
new interruptions appeared later in the process lifetime.

Symptom: a sync that started after server boot, crashed mid-flight
(deadlock retry hitting maxRetries, network blip, container
restart of an upstream service, etc.) and never reached the resume
codepath would sit at `inProgress=true, lastCheckpoint=never`
forever. The periodic detector kept finding the stuck row and
logging `Found 1 interrupted jobs:` on every poll (driven by the
health endpoint via `hasJobsNeedingRecovery`), but the resumer
(`resumeInterruptedJob`) was only invoked from `initializeRecovery`
which the middleware never re-called.

Fix: replace the one-shot gate with an in-flight latch
(`recoveryInFlight`) that's released in a `finally` block. Throttling
of actual recovery work is delegated to the existing 5-minute
`skipIfRecentAttempt` check inside `initializeRecovery()`, which is
the right place for it. `recoveryInitialized` is kept and only used
to control whether the "first run" log lines fire.

Secondary fix: `findInterruptedJobs` previously logged
`Found N interrupted jobs:` unconditionally on every call, including
from passive polls in `hasJobsNeedingRecovery` (which the health
endpoint and middleware probe call frequently). That produced one
log line per poll per stuck job for as long as the job stayed stuck.
Make logging opt-in via a `logFound` parameter, default off; the
active recovery cycle in `initializeRecovery()` opts in so the
operator-facing log still surfaces which jobs are being worked on.

Related: #268 (partially addressed). The PR #280 / v3.15.7 fix was
about a JS scoping bug that made the *initial* migrate call's
catch path crash before transitioning the repo to `failed`. That
was one cause of stuck `mirroring` state; this PR addresses the
follow-on issue that even when a job *is* correctly detectable as
interrupted, the post-startup recovery path never re-engages.

Adds `orchestrator-resume-after-startup.test.ts` using the
structural-source test pattern from
`gitea-mirror-failure-recovery.test.ts` so the four guarantees
(no static gate; in-flight latch released in finally; logging
opt-in; active path opts in) are enforced without heavy mocks.
2026-05-23 20:08:33 +05:30
Sean Mousseau b9f14e55e2 fix: prevent duplicate issues & PRs on retry-after-deadlock + Link-header pagination (#296)
mirrorGitRepoIssuesToGitea and mirrorGitRepoPullRequestsToGitea both
had two compounding bugs that produced duplicate Gitea issues/PR-as-
issue rows on every sync against any non-SQLite backend.

(1) Pagination on existing-issues / existing-PRs / per-issue-comments
was wrong.

The loops paginated with `limit=100` and broke on `pageX.length <
itemsPerPage`. Gitea caps response size at server-side
[api].MAX_RESPONSE_ITEMS (default 50), so the very first page
already looks "short" and pagination terminated after one page. The
existing-issue and existing-PR maps were built from only ~50 items
per repo, so every issue/PR past page 1 was treated as new on every
sync and re-created via the CREATE branch.

Naive removal of the short-page break (relying only on "break on
empty page") doesn't work either: for some Gitea endpoints Gitea
returns the same data on every page when the page is past the
actual end instead of returning [], so the loop runs forever.

Fix: use the Link header (RFC 5988). If `rel="next"` is absent,
terminate pagination. Applied to: existing-issues pre-fetch,
existing-PRs pre-fetch, and per-issue comments fetch.

(2) Retry-after-deadlock duplicated issues/PRs even when the map
was correct.

Gitea's CreateIssue handler commits the issue insert in one
transaction and then deadlocks on the subsequent addLabel /
UPDATE repository transaction. The issue row is committed and
visible, but the in-memory dedup maps are never refreshed between
retries — processWithRetry re-invokes the callback, sees
existingIssue === undefined from the stale map, and creates a fresh
duplicate via httpPost.

Reproduces deterministically on MySQL (Error 1213 / 40001) and
PostgreSQL (40P01); SQLite escapes because writes serialize globally.

Fix: defensive recheck via httpGet by [GH-ISSUE #N] (issues) or
[PR #N] (pull requests) before the create call, with PATCH
fall-through when found. Applied to the issues create path AND
both create paths in the PR mirror (enriched + basic-fallback).
Also cache freshly-created items into the dedup maps after a
successful create so subsequent retries of the same per-item
callback don't lose track of it.

Adds gitea-issue-dedup-on-retry.test.ts using the structural-source
test pattern from gitea-mirror-failure-recovery.test.ts so all
guarantees are enforced without heavy module mocks (8 tests).
2026-05-23 20:08:23 +05:30
github-actions[bot] 2582988f94 chore: sync version to 3.16.0 2026-05-19 07:31:20 +00:00
ARUNAVO RAY 4ea62a9f3d chore: bump and digest-pin Bun base image to 1.3.14 (#295)
Same hardening principle as #293 for GitHub Actions: pin to an immutable
identifier so a future tag move can't silently change what we build against.

- Bumps oven/bun from 1.3.13 to 1.3.14 (released 2026-05-13)
- Pins to multi-arch digest sha256:9dba1a1b...db6f rather than just the tag
- Applies to both base and runner stages
2026-05-19 12:50:37 +05:30
ARUNAVO RAY 1f60b2cf39 feat: add Change password and Change email to account dropdown (#292)
Adds an account menu under the avatar in the header with Change password and
Change email actions, each opening a small dialog. Calls Better Auth's existing
change-password / change-email endpoints — no new API routes.

- Enables `user.changeEmail` in Better Auth with `updateEmailWithoutVerification`
  since the app runs with email verification disabled and no email sender is
  wired up
- Hides Change password for SSO-only users (no `credential` provider account),
  fails open if the listAccounts probe errors
- Resolves discussion #291 ("Change user password/email")
2026-05-19 12:45:04 +05:30
ARUNAVO RAY a02865a1aa ci: pin third-party GitHub Actions to commit SHAs (#293)
Tags are mutable. A compromised maintainer (or a maintainer's compromised
machine) can force-move v-tags to point at malicious commits, and any workflow
using `@vN` picks up the malicious code on its next run — see the recent
`actions-cool/issues-helper` / `maintain-one-comment` incident exfiltrating
credentials from `Runner.Worker` memory.

This commit pins every third-party action in the two workflows that handle
secrets (GHCR push, Docker Hub login, Scout token) to immutable 40-char SHAs,
with a trailing comment naming the release version for readability. SHAs are
the latest released tag at time of pin.

The two DeterminateSystems actions were on `@main` — a *branch* ref that moves
on every push, materially worse than a tag — and are now pinned to the latest
release SHAs (v22 / v13).

First-party `actions/*` and `github/codeql-action` are left on tags for now;
they're a separate, lower-risk follow-up.
2026-05-19 12:44:23 +05:30
github-actions[bot] ad549dad9b chore: sync version to 3.15.12 2026-05-16 06:50:50 +00:00
ARUNAVO RAY 20103220d9 chore: prune npm overrides that are no longer load-bearing (#290)
Removed 5 overrides whose constraints have since been picked up naturally
by the transitive dep graph. Verified by removing each and confirming the
resolved version (and the dep tree) is identical to what the override
produced:

- defu ^6.1.7 → still resolves 6.1.7
- fast-xml-parser ^5.5.6 → still resolves 5.5.6
- node-forge ^1.3.3 → package not in tree at all (override was dead)
- rollup >=4.59.0 → still resolves 4.59.0
- svgo ^4.0.1 → still resolves 4.0.1

Kept overrides that are still doing real work:

- @esbuild-kit/esm-loader → npm:tsx@^4.21.0 — deliberate replacement shim
- @xmldom/xmldom ^0.8.13, devalue ^5.8.1, fast-uri ^3.1.2,
  fast-xml-builder ^1.1.7, kysely ^0.28.17 — active CVE pins (#289)
- lodash ^4.18.1 — pins to the newer 4.18.x line over the legacy 4.17.x
  that transitive deps still pull
- picomatch ^4.0.4 — without it, picomatch@2.3.2 is added as a duplicate
  copy via a transitive that asks for 2.x

Future drift would be caught by Dependabot + the weekly Docker Scout
scan; the overrides above remain because they currently affect the tree.
2026-05-16 12:03:53 +05:30
ARUNAVO RAY fe2c825244 chore: bump npm overrides to patch HIGH-severity CVEs (#289)
Patches 9 Docker Scout HIGH alerts surfaced by the weekly image scan:

- @xmldom/xmldom 0.8.12 → 0.8.13 (CVE-2026-41672/3/4/5)
- devalue 5.6.4 → 5.8.1 (CVE-2026-42570)
- kysely 0.28.16 → 0.28.17 (CVE-2026-44635)
- fast-uri (new override) → ^3.1.2 (CVE-2026-6321, CVE-2026-6322)
- fast-xml-builder (new override) → ^1.1.7 (CVE-2026-44665)

All five resolve to fixed versions after `bun install`. Tests and astro
build pass locally.

Remaining open Docker Scout alerts (git-lfs Go stdlib, gnutls28, nghttp2)
are base-image or upstream-binary issues, not addressable via npm.
2026-05-16 11:42:53 +05:30
github-actions[bot] 4b858a0251 chore: sync version to 3.15.11 2026-05-16 04:28:38 +00:00
Eduardo Riguetto (Kralot) 585d2ceb84 fix: reconcile metadata on every sync instead of once per repo (#287)
Previously, mirror of issues/pull-requests/labels/milestones was
guarded by !metadataState.components.<component>, so once a repo had
been mirrored the metadata path was permanently skipped with logs
like "Issues already mirrored; skipping to avoid duplicates".

This meant title changes, new comments, label updates and milestone
edits on the source GitHub repo were never propagated, even when the
user explicitly clicked Sync.

The underlying mirror* functions already handle idempotent updates:
issues and PRs are matched by [GH-ISSUE #N] / [GH-PR #N] markers in
the title and PATCHed in place, labels are deduped by name, milestones
by title. The releases path already runs unconditionally for the same
reason ("always allowed to rerun for updates"); aligning the other
metadata paths with it.

Touched: mirrorGithubRepoToGitea, mirrorGitHubRepoToGiteaOrg, and the
syncGiteaRepoEnhanced path in gitea-enhanced.ts. Updated the
"already-synced-repo" test to assert reconciliation runs on resync.
2026-05-16 09:40:48 +05:30
Eduardo Riguetto (Kralot) 7c1f24dc2f fix: include organization_member in /user/repos affiliation (#286)
Affiliation was set to "owner,collaborator", omitting repos owned
by orgs the user belongs to. As a result:

- main sync, scheduler, and cleanup never saw org repos
- orgs appeared empty unless manually re-added via /api/sync/organization
- restart archived previously-mirrored org repos as orphans

GitHub's API default is owner,collaborator,organization_member;
restoring it fixes both symptoms with no other code changes.
2026-05-16 09:40:40 +05:30
github-actions[bot] 680b374c84 chore: sync version to 3.15.10 2026-05-04 08:37:38 +00:00
ARUNAVO RAY 088467a57d feat: add option to exclude collaborator repos from import (closes #279) (#283)
GitHub's listForAuthenticatedUser defaults to returning every repo the
user has access to (owner + collaborator + organization_member), which
imports a lot of noise for users who only want their own repos.

Adds an `includeCollaboratorRepos` toggle, defaulting to true to preserve
existing behavior. When disabled, the affiliation filter scopes the API
call to "owner" only.

The cleanup service overrides the filter to always include collaborator
repos when computing the "what's still on GitHub" list. Without this,
toggling the option off would mark previously-mirrored collab repos as
orphaned and archive/delete them from Gitea.

Wired through the schema, both UI<->DB mappers, the env-config loader
(with new INCLUDE_COLLABORATOR_REPOS env var), and the settings UI.
2026-05-04 14:00:10 +05:30
github-actions[bot] adb436444e chore: sync version to 3.15.9 2026-05-04 04:50:49 +00:00
ARUNAVO RAY cc635485f0 feat: surface auto-mirror toggle in automation settings (refs #278) (#282)
The fix in v3.15.8 made scheduleConfig.autoMirror an independent trigger
in the scheduler, but it remained reachable only via the AUTO_MIRROR_REPOS
env var. This adds a UI checkbox under the Automatic Syncing section so
the option can be toggled per-config without touching the environment.

The toggle is conditional on scheduling being enabled (since auto-mirror
without a scheduler is meaningless) and is independent of the existing
"Auto-mirror new starred repositories" toggle in GitHub settings. Together
they cover the full owned/starred matrix that the scheduler already
supports.

Plumbing: config-mapper.ts now round-trips autoMirror through the UI/DB
boundary, and ScheduleConfig in types/config.ts gets the matching field.
No schema or migration change — autoMirror was already in the zod schema.
2026-05-04 10:14:09 +05:30
github-actions[bot] 6f343de5fd chore: sync version to 3.15.8 2026-05-04 03:49:44 +00:00
ARUNAVO RAY a18f262ca7 fix: make autoMirrorStarred actually trigger auto-mirror (fixes #278) (#281)
The "Auto-mirror new starred repositories" checkbox in the GitHub settings
was a filter layered on top of scheduleConfig.autoMirror, which itself is
only settable via the AUTO_MIRROR_REPOS env var (no UI). So users who
checked the box saw their starred repos auto-imported but never mirrored.

Treat autoMirror and autoMirrorStarred as independent triggers in the
scheduler: autoMirror covers owned (and self-starred) repos, autoMirrorStarred
covers repos starred from other owners. Either flag on its own is enough
to enter the auto-mirror phase, and the filter scopes the work accordingly.

Also normalize the owner comparison to lowercase since GitHub usernames are
case-insensitive — previously a self-starred repo whose stored owner casing
differed from the configured owner would be misclassified as a third-party
star.

Behavior change worth flagging in release notes: anyone who currently has
the starred checkbox on (broken state) will start getting starred repos
mirrored on upgrade. AUTO_MIRROR_REPOS=true users see no change.
2026-05-04 09:12:57 +05:30
Arunavo Ray 3798456f5d chore: bump version to 3.15.7 2026-05-04 08:20:26 +05:30
ARUNAVO RAY 73f1609117 fix: unstick repos in 'mirroring' on transient errors (fixes #268) (#280)
* fix: hoist migrateSucceeded above try so catch can update DB on failure (fixes #268)

`let migrateSucceeded` was declared inside the try block of
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the catch
block referenced it. Block-scoping made it invisible to catch, so any
error inside the try (network timeout, transient 5xx, etc.) crashed the
catch with `ReferenceError: migrateSucceeded is not defined` before
reaching the DB update that marks the repo "failed". Result: repos
stuck in "mirroring" forever with no entry in the activity log.

Hoisting the declaration above the try restores the intended behavior:
catch updates the repo to failed, clears mirroredLocation when migrate
hadn't succeeded, writes a failed activity-log entry, and re-throws
with the original error message preserved.

TypeScript was flagging this as "Cannot find name 'migrateSucceeded'"
but esbuild stripped the types during build, so the bug shipped.

* test: replace integration test with structural source check (#268)

The behavioral version of this regression test passed locally but
failed in CI because of mock.module pollution between files: bun's
mock.module is process-wide, so my mock for @/lib/gitea-enhanced
leaked into gitea-enhanced.test.ts (its real-module assertions saw
my null-returning mocks), and gitea-enhanced.test.ts's own
@/lib/http-client mock could supersede mine depending on file
discovery order, causing my mirrorGithubRepoToGitea call to not
throw at all in CI.

Replace with a structural assertion that reads gitea.ts and verifies
`let migrateSucceeded` is declared before the outermost try in both
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg. Verified the
new test fails on the pre-fix source with a clear error message
pointing to issue #268, and passes on the fixed source.
2026-05-04 08:08:22 +05:30
Arunavo Ray 588567931a chore: bump version to 3.15.6 2026-04-26 13:43:45 +05:30
Arunavo Ray 5c1317c759 feat: warn when Forgejo destination has known mirror-credential bug (refs #263)
Forgejo < 15.0.0 silently discards auth_username/auth_password sent to
/api/v1/repos/migrate, causing subsequent pull-mirror sync of private repos
to fail with `terminal prompts disabled`. Fix landed upstream in Forgejo
v15.0.0 via codeberg.org/forgejo/forgejo/pulls/11909 and was not backported
to v12/v13/v14.

Test-connection endpoint now also probes /api/v1/version, detects Forgejo
via the `+gitea-` suffix, and surfaces a warning Alert in the Gitea config
form when the connected server reports a major version below 15.
2026-04-26 13:43:40 +05:30
Arunavo Ray 5f1c37b320 fix: don't gate dashboard on optional username fields (refs #271, v3.15.5)
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.

Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.

Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
2026-04-22 19:21:13 +05:30
Arunavo Ray 083b342f38 ci: bump bun 1.3.10/1.3.12 → 1.3.13 across CI and runtime
CI was on 1.3.10 while the Dockerfile runtime moved to 1.3.12 in v3.15.2,
so we were testing against an older runtime than we shipped. Align both
on 1.3.13 (latest stable). May also resolve the intermittent --coverage
instrumentation flake observed on 1.3.10 against http-client.ts.
2026-04-22 08:39:37 +05:30
Arunavo Ray 92bb38b122 chore: bump version to 3.15.4 2026-04-22 08:11:17 +05:30
dependabot[bot] e7ac54a72a build(deps): bump astro (#274)
Bumps the npm_and_yarn group with 1 update in the /www directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 6.0.4 to 6.1.6
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.1.6/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.1.6
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 08:10:43 +05:30
Arunavo Ray 2ea250f081 fix: prefer active config when reading user settings (fixes #271)
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.

In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.

Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.

Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.

Updates the mirror-repo test mock to match the new orderBy().limit()
chain.

Closes #271
2026-04-22 08:01:22 +05:30
Arunavo Ray c1712bc670 chore: bump version to 3.15.3 2026-04-20 13:03:28 +05:30
ARUNAVO RAY c4550196e9 fix: honor GH_API_URL across all Octokit call sites (#269) (#273)
* fix: honor GH_API_URL across all Octokit call sites

Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).

Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)

Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.

`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.

Fixes #269

* fix: address review findings

- scheduler: pass config.githubConfig?.owner (the real DB field) instead
  of ?.username, which doesn't exist on the DB row and was silently
  resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
  public-repo lookup to preserve fast-fail on the 60 req/hr limit.
  Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
  data-residency users benefit. The throttling plugin's retry-with-
  backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
  remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
  mutable stub reference — safer against ESM live-binding semantics.
2026-04-20 13:02:34 +05:30
Arunavo Ray 8cb8fd6fe1 docs: document GH_API_URL for GitHub Enterprise and SERVER_CERT_PATH/SERVER_KEY_PATH for native HTTPS
- README + env reference + .env.example now cover using GH_API_URL to
  target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
  SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
  TLS directly without a reverse proxy.

Closes #269
Closes #272
2026-04-20 09:30:08 +05:30
Arunavo Ray 4b4ea9614b chore: bump runtime and patch npm CVEs (v3.15.2)
Dockerfile:
- oven/bun 1.3.10 → 1.3.12
- Go 1.25.8 → 1.25.9 (fixes CVE-2026-32280/32281/32283 in git-lfs stdlib)

npm:
- drizzle-orm ^0.45.1 → ^0.45.2 (fixes CVE-2026-39356)
- override defu ^6.1.7 (fixes CVE-2026-35209)
- override @xmldom/xmldom ^0.8.12 (fixes CVE-2026-34601)
- override picomatch ^4.0.4 (fixes CVE-2026-33671, both instances)
- override kysely ^0.28.16 (fixes CVE-2026-33442, CVE-2026-33468)
- override lodash ^4.18.1 (fixes CVE-2026-4800)

Remaining unfixable alert: CVE-2026-27135 (libnghttp2) has no upstream
fix yet. System openssl CVEs are resolved by the Debian base image's
apt-get upgrade step.
2026-04-17 00:47:25 +05:30
Arunavo Ray 1644505043 chore: bump version to 3.15.1 2026-04-17 00:30:48 +05:30
ARUNAVO RAY e142524bfc fix: prevent duplicate issue/PR mirroring, enforce release retention, fix public repo auth, preserve env-loader backup settings
- Add metadata state guards for issues/PRs in syncGiteaRepoEnhanced to
  prevent re-mirroring on every sync (fixes #262)
- Fix httpPut → httpPatch for Gitea release updates (HTTP 405 error)
- Add paginated release retention cleanup to enforce releaseLimit by
  deleting the oldest excess releases (fixes #264)
- Always send auth credentials during migration for all repos, not just
  private ones, preventing "terminal prompts disabled" on Forgejo (fixes #263)
- Add missing service: "git" to org migration payload
- Make buildGithubSourceAuthPayload return {} instead of throwing when
  token is missing, allowing unconditional use
- Preserve existing backup settings in env-config-loader so UI-configured
  backup strategy and retention values survive restart (fixes #267)

Closes #262, #263, #264, #267
2026-04-17 00:30:15 +05:30
Arunavo Ray 8fac30fc02 docs: clarify BETTER_AUTH_URL should be origin only, not include base path
Update README, ENVIRONMENT_VARIABLES.md, and advanced docs page to
explicitly state that BETTER_AUTH_URL and PUBLIC_BETTER_AUTH_URL must be
origin only (scheme + host). The BASE_URL path prefix is applied
automatically — any path accidentally included is stripped.
2026-04-09 20:11:00 +05:30
Arunavo Ray c3b1f933b1 chore: bump version to 3.15.0 2026-04-09 12:45:24 +05:30
dependabot[bot] a839915e3e build(deps): bump vite (#261)
Bumps the npm_and_yarn group with 1 update in the /www directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 12:44:27 +05:30
dependabot[bot] 5d95f4cd39 build(deps): bump defu (#259)
Bumps the npm_and_yarn group with 1 update in the /www directory: [defu](https://github.com/unjs/defu).


Updates `defu` from 6.1.4 to 6.1.6
- [Release notes](https://github.com/unjs/defu/releases)
- [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.6)

---
updated-dependencies:
- dependency-name: defu
  dependency-version: 6.1.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 12:33:12 +05:30
ARUNAVO RAY 01a3b08dac feat: support reverse proxy path prefix deployments (#257)
* feat: support reverse proxy path prefixes

* fix: respect BASE_URL in SAML callback fallback

* fix: make BASE_URL runtime configurable
2026-04-09 12:32:59 +05:30
Arunavo Ray c87513b648 chore: bump version to 3.14.2 2026-03-27 13:55:56 +05:30
ARUNAVO RAY 4f3cbc866e fix private github mirror auth (#255) 2026-03-27 13:49:36 +05:30
ARUNAVO RAY 60548f2062 fix sync target resolution for mirrored repos (#249) 2026-03-27 12:33:59 +05:30
dependabot[bot] 74dab43e89 build(deps): bump picomatch (#251)
Bumps the npm_and_yarn group with 1 update in the /www directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 09:39:17 +05:30
dependabot[bot] 01a8025140 build(deps): bump smol-toml (#250)
Bumps the npm_and_yarn group with 1 update in the /www directory: [smol-toml](https://github.com/squirrelchat/smol-toml).


Updates `smol-toml` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 22:35:57 +05:30
Arunavo Ray 8346748f5a fix: move --accept-flake-config before -- in bun2nix step
The flag was being passed to bun2nix instead of nix, causing
"unexpected argument" error.
2026-03-24 08:22:04 +05:30
Arunavo Ray 38002019ea fix: regenerate bun.nix in CI to prevent stale dependency errors
The Nix build has been failing since v3.9.6 because bun.nix fell out
of sync with bun.lock. During the sandboxed build bun install cannot
fetch missing packages, causing ConnectionRefused errors.

- Add bun2nix regeneration step before nix build in CI
- Trigger workflow on bun.lock and package.json changes
- Update flake.nix version from 3.9.6 to 3.14.1
2026-03-24 08:20:26 +05:30
Arunavo Ray 32eb27c8a6 chore: bump version to 3.14.1 2026-03-24 07:35:36 +05:30
dependabot[bot] d33b4ff64f build(deps): bump h3 (#244)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.8 to 1.15.9
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/v1.15.9/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.8...v1.15.9)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 07:34:40 +05:30
ARUNAVO RAY 6f2e0cbca0 Add GitHub starred-list filtering with searchable selector (#247)
* feat: add starred list filtering and selector UI

* docs: add starred lists UI screenshot

* lib: improve starred list name matching
2026-03-24 07:33:46 +05:30
dependabot[bot] 95e6eb7602 build(deps): bump h3 (#242)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.5 to 1.15.8
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.5...v1.15.8)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 08:24:09 +05:30
Arunavo Ray f50f49fc41 chore: bump version to 3.14.0 2026-03-19 00:59:59 +05:30
ARUNAVO RAY 5ea2abff85 feat: custom sync start time and frequency scheduling (#241)
* feat: add custom sync start time scheduling

* Updated UI

* docs: add updated issue 240 UI screenshot

* fix: improve schedule UI with client-side next run calc and timezone handling

- Compute next scheduled run client-side via useMemo to avoid permanent
  "Calculating..." state when server hasn't set nextRun yet
- Default to browser timezone when enabling syncing (not UTC)
- Show actual saved timezone in badge, use it consistently in all handlers
- Match time input background to select trigger in dark mode
- Add clock icon to time picker with hidden native indicator
2026-03-19 00:58:10 +05:30
Arunavo Ray 9d131b9a09 fix security alerts 2026-03-18 20:10:45 +05:30
github-actions[bot] 5f77fceaca chore: sync version to 3.13.4 2026-03-18 13:15:13 +00:00
ARUNAVO RAY 5d2462e5a0 feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231)

Add push notification support for mirror job events with two providers:

- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services

Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.

* fix: address review findings for notification system

- Fix silent catch in GET handler that returned ciphertext to UI,
  causing double-encryption on next save. Now clears token to ""
  on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
  project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
  since the backend doesn't yet emit new_repo events. The schema
  and type support is in place for when it's implemented.

* fix notification gating and config validation

* trim sync notification details
2026-03-18 18:36:51 +05:30
ARUNAVO RAY 0000a03ad6 fix: improve reverse proxy support for subdomain deployments (#237)
* fix: improve reverse proxy support for subdomain deployments (#63)

- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
  from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
  so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
  explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
  BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
  docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example

* fix: address review findings for reverse proxy origin detection

- Fix x-forwarded-proto multi-value handling: take first value only
  and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
  with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging

* fix: handle multi-value x-forwarded-host in chained proxy setups

x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.

* test: add unit tests for reverse proxy origin detection

Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
2026-03-18 15:47:15 +05:30
ARUNAVO RAY d697cb2bc9 fix: prevent starred repo name collisions during concurrent mirroring (#236)
* fix: prevent starred repo name collisions during concurrent mirroring (#95)

When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.

Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
  repositories table for existing mirroredLocation claims, preventing two
  concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
  hold a location that was never successfully created, which would block
  retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
  ne() to exclude the current repo's own record from the collision check.

* fix: address review findings for starred repo name collision fix

- Make generateUniqueRepoName immediately claim name by writing
  mirroredLocation to DB, closing the TOCTOU race window between
  name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
  DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
  preserving it for non-starred repos that may have partially
  created in Gitea and need the location for recovery

* fix: address P1/P2 review findings for starred repo name collision

P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.

P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.

P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
2026-03-18 15:27:20 +05:30
ARUNAVO RAY ddd071f7e5 fix: prevent excessive disk usage from repo backups (#235)
* fix: prevent excessive disk usage from repo backups (#234)

Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.

Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
  instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
  preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
  count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior

* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days

P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.

P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
2026-03-18 15:05:00 +05:30
255 changed files with 43334 additions and 9021 deletions

No files matched your search

+45 -2
View File
@@ -9,6 +9,8 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
BASE_URL=/
# Database Configuration
# For self-hosted, SQLite is used by default
@@ -18,8 +20,42 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# Better Auth log verbosity: debug | info | warn | error (default: warn).
# Set to "debug" to trace SSO/OIDC sign-in and callback issues.
# BETTER_AUTH_LOG_LEVEL=debug
# ===========================================
# REVERSE PROXY CONFIGURATION
# ===========================================
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
#
# Set all three to your external URL, e.g.:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
#
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
#
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS=
# ===========================================
# HTTPS / TLS (Optional)
# ===========================================
# Set BOTH to have the server terminate TLS directly (no reverse proxy needed).
# Leave unset when TLS is handled upstream by Nginx/Traefik/Caddy.
# SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
# SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
# ===========================================
# DOCKER CONFIGURATION (Optional)
@@ -27,7 +63,7 @@ BETTER_AUTH_URL=http://localhost:4321
# Docker Registry Configuration
DOCKER_REGISTRY=ghcr.io
DOCKER_IMAGE=raylabshq/gitea-mirror:
DOCKER_IMAGE=raylabshq/gitea-mirror
DOCKER_TAG=latest
# ===========================================
@@ -40,12 +76,19 @@ DOCKER_TAG=latest
# GITHUB_TOKEN=your-github-personal-access-token
# GITHUB_TYPE=personal # Options: personal, organization
# GitHub Enterprise (GHES / GHEC with data residency)
# Leave unset for standard github.com. Examples:
# GHES (self-hosted): https://ghe.example.com/api/v3
# GHEC data residency: https://api.TENANT.ghe.com
# GH_API_URL=https://ghe.example.com/api/v3
# Repository Selection
# PRIVATE_REPOSITORIES=false
# PUBLIC_REPOSITORIES=true
# INCLUDE_ARCHIVED=false
# SKIP_FORKS=false
# MIRROR_STARRED=false
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
# STARRED_REPOS_ORG=starred # Organization name for starred repos
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.3.10'
bun-version: '1.3.13'
- name: Check lockfile and install dependencies
run: |
+24 -9
View File
@@ -51,13 +51,13 @@ jobs:
ref: ${{ env.SHA }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver-opts: network=host
- name: Log into registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -66,7 +66,7 @@ jobs:
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
- name: Log into Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -108,7 +108,7 @@ jobs:
# Extract metadata for Docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
labels: |
@@ -124,7 +124,7 @@ jobs:
# Build and push Docker image
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -139,7 +139,7 @@ jobs:
# Load image locally for security scanning (PRs only)
- name: Load image for scanning
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64
@@ -212,7 +212,7 @@ jobs:
# Docker Scout comprehensive security analysis
- name: Docker Scout - Vulnerability Analysis & Recommendations
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name != 'pull_request'
with:
command: cves,recommendations
@@ -226,7 +226,7 @@ jobs:
# Docker Scout for Pull Requests (using local image)
- name: Docker Scout - Vulnerability Analysis (PR)
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request'
with:
command: cves,recommendations
@@ -240,7 +240,7 @@ jobs:
# Compare to latest for PRs and pushes
- name: Docker Scout - Compare to Latest
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request'
with:
command: compare
@@ -251,6 +251,21 @@ jobs:
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# GitHub rejects SARIF files where a single result carries more than
# 1000 relatedLocations, which Scout produces for widely-referenced OS
# packages — every upload had been silently failing on this since May
# ("rejecting SARIF, as there are more related locations per result than
# allowed"). Cap them so uploads flow and stale alerts can auto-close.
- name: Sanitize Scout SARIF (cap relatedLocations)
if: always()
continue-on-error: true
run: |
if [ -f scout-results.sarif ]; then
jq '(.runs[]?.results[]?) |= (if .relatedLocations then .relatedLocations |= .[:100] else . end)' \
scout-results.sarif > scout-results.sanitized.sarif
mv scout-results.sanitized.sarif scout-results.sarif
fi
# Upload security scan results to GitHub Security tab
- name: Upload Docker Scout scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
+19 -1
View File
@@ -40,7 +40,7 @@ env:
FAKE_GITHUB_PORT: 4580
GIT_SERVER_PORT: 4590
APP_PORT: 4321
BUN_VERSION: "1.3.10"
BUN_VERSION: "1.3.13"
jobs:
e2e-tests:
@@ -88,6 +88,24 @@ jobs:
- name: Start Gitea and git-server containers
run: |
# Retry pulls before `up` — Docker Hub occasionally returns
# "context deadline exceeded" on the first attempt. Pulling
# separately also keeps the failure mode obvious if it persists.
echo "Pulling images..."
for attempt in 1 2 3; do
if docker compose -f tests/e2e/docker-compose.e2e.yml pull --quiet; then
echo "✓ Images pulled (attempt $attempt)"
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: docker compose pull failed after 3 attempts"
exit 1
fi
sleep_s=$((attempt * 10))
echo "Pull attempt $attempt failed, retrying in ${sleep_s}s..."
sleep "$sleep_s"
done
echo "Starting containers via docker compose..."
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
+17 -2
View File
@@ -9,6 +9,8 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml'
pull_request:
branches: [main]
@@ -16,6 +18,8 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml'
permissions:
@@ -34,10 +38,21 @@ jobs:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- name: Regenerate bun.nix from bun.lock
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
- name: Fail if committed bun.nix is out of date
run: |
if ! git diff --quiet -- bun.nix; then
echo '::error file=bun.nix::bun.nix is out of date! Run `nix run github:nix-community/bun2nix -- -o bun.nix` (requires Nix) and commit the result.'
git diff --stat -- bun.nix
exit 1
fi
- name: Check flake
run: nix flake check --accept-flake-config
+1
View File
@@ -52,3 +52,4 @@ tests/e2e/git-repos/
/blob-report/
/playwright/.cache/
/playwright/.auth/
.playwright-mcp/
+11 -7
View File
@@ -1,8 +1,8 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.10-debian AS base
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -16,6 +16,7 @@ COPY . .
RUN bun run build
RUN mkdir -p dist/scripts && \
for script in scripts/*.ts; do \
if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
done
@@ -28,10 +29,10 @@ RUN bun install --production --omit=peer --frozen-lockfile
# ----------------------------
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs
FROM debian:trixie-slim AS git-lfs-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.8
ARG GO_VERSION=1.25.12
ARG GIT_LFS_VERSION=3.7.1
RUN ARCH="$(dpkg --print-architecture)" \
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
@@ -43,14 +44,15 @@ ENV GOTOOLCHAIN=local
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
&& cd /tmp/git-lfs \
&& go get golang.org/x/crypto@latest \
&& go get golang.org/x/net@latest \
&& go mod tidy \
&& make \
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
# ----------------------------
FROM oven/bun:1.3.10-debian AS runner
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
@@ -59,6 +61,7 @@ COPY --from=pruner /app/node_modules ./node_modules
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/runtime-server.ts ./scripts/runtime-server.ts
COPY --from=builder /app/drizzle ./drizzle
# Remove build-only packages that are not needed at runtime
@@ -73,6 +76,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
ENV BASE_URL=/
# Create directories and setup permissions
RUN mkdir -p /app/certs && \
@@ -90,6 +94,6 @@ VOLUME /app/data
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
CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"]
+45 -12
View File
@@ -21,6 +21,8 @@ docker compose -f docker-compose.alt.yml up -d
First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the web interface!
📖 **[Full documentation](https://gitea-mirror.raylabs.io/docs/)** - quickstart, deployment, configuration, notifications, SSO and more. Offline copies live in [`docs/`](docs/).
<p align="center">
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
<img src=".github/assets/dashboard_mobile.png" alt="Dashboard Mobile" width="200" />
@@ -29,18 +31,20 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
## ✨ Features
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 📦 **Git LFS support** - Mirror large files with Git LFS
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, releases, wiki
- 🚫 **Repository ignore** - Mark specific repos to skip
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC, header auth)
- 🔔 **[Notifications](docs/NOTIFICATIONS.md)** - ntfy, Apprise, Gotify, or any webhook with signed payloads
- 📊 Real-time dashboard with activity logs
- ⏱️ Scheduled automatic mirroring with configurable intervals
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes (Beta)
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub
- 🎯 **Proper mirror intervals** - Respects configured sync intervals
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes
- 🗑️ Automatic database cleanup with configurable retention
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
@@ -48,7 +52,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
<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" />
<img src=".github/assets/repositories_mobile.png" alt="Repositories Mobile" width="200" />
</div>
<div align="center">
@@ -296,11 +300,40 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
- **The Whole Point of Backups**: Your Gitea/Forgejo mirrors are preserved even when GitHub sources disappear - that's why you have backups!
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
### GitHub Enterprise (GHES / GHEC with Data Residency)
Gitea Mirror works with non-`github.com` GitHub deployments. Point the client at your Enterprise API via the `GH_API_URL` environment variable:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance for `GITHUB_TOKEN`.
## Troubleshooting
### Reverse Proxy Configuration
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BASE_URL` sets the application path prefix.
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` should be **origin only** (e.g. `https://git.example.com`). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` should also contain origins only (no path).
- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
### Mirror Token Rotation (GitHub Token Changed)
@@ -488,11 +521,11 @@ GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file
## Star History
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&type=date&legend=bottom-right">
<a href="https://www.star-history.com/?repos=RayLabsHQ%2Fgitea-mirror&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=bottom-right" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
</picture>
</a>
+647 -446
View File
File diff suppressed because it is too large. Load diff
+892 -652
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -18,6 +18,11 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
# === CORE SETTINGS ===
# These are technically required but have working defaults
@@ -25,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1
@@ -32,7 +38,11 @@ services:
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 10s
retries: 5
+6 -1
View File
@@ -66,6 +66,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +90,11 @@ services:
# 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"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 5s
retries: 3
+18 -1
View File
@@ -30,8 +30,21 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
# you MUST set these three variables to your external URL. Example:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
# GitHub/Gitea Mirror Config
@@ -74,7 +87,11 @@ services:
- 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"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 10s
retries: 5
+7 -1
View File
@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
# Start the application
echo "Starting Gitea Mirror..."
bun ./dist/server/entry.mjs &
if [ -f "./scripts/runtime-server.ts" ]; then
bun ./scripts/runtime-server.ts &
elif [ -f "./dist/scripts/runtime-server.js" ]; then
bun ./dist/scripts/runtime-server.js &
else
bun ./dist/server/entry.mjs &
fi
APP_PID=$!
# Wait for the application to finish
+69 -4
View File
@@ -16,6 +16,7 @@ When environment variables are set:
## Table of Contents
- [Core Configuration](#core-configuration)
- [HTTPS / TLS](#https--tls)
- [GitHub Configuration](#github-configuration)
- [Gitea Configuration](#gitea-configuration)
- [Mirror Options](#mirror-options)
@@ -33,13 +34,39 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | 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` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
## HTTPS / TLS
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
**Example (systemd or `.env`):**
```bash
SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
PORT=443
BETTER_AUTH_URL=https://mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
```
Notes:
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
- Works in Docker too — mount your certs and set both paths to locations inside the container.
## GitHub Configuration
Settings for connecting to and configuring GitHub repository sources.
@@ -51,6 +78,21 @@ Settings for connecting to and configuring GitHub repository sources.
| `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` |
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
### GitHub Enterprise (GHES / GHEC with data residency)
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
### Repository Selection
@@ -59,10 +101,13 @@ Settings for connecting to and configuring GitHub repository sources.
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
| `STARRED_DUPLICATE_STRATEGY` | Name collision strategy when two starred repos share a name from different owners (`suffix` = `repo-owner`, `prefix` = `owner-repo`) | `suffix` | `suffix`, `prefix`, `owner-org` |
### Organization Settings
@@ -70,7 +115,8 @@ Settings for connecting to and configuring GitHub repository sources.
|----------|-------------|---------|---------|
| `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` |
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` |
| `INCLUDE_ORGANIZATIONS` | Opt-in allowlist: only mirror repos from these organizations (empty = all orgs you belong to). Sets `includeOrganizations` in GitHub config | - | Comma-separated org names |
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
### Advanced Settings
@@ -233,7 +279,7 @@ Configure automatic cleanup of old events and data.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
| `CLEANUP_DELETE_FROM_GITEA` | Apply the orphaned-repo action on the Gitea side too. When `false` (default), cleanup only updates gitea-mirror's own database (orphans are marked archived or removed from the repo list) and the Gitea/Forgejo copies are left untouched | `false` | `true`, `false` |
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
@@ -301,6 +347,7 @@ services:
environment:
# Core Configuration
- NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL:
@@ -369,6 +416,24 @@ This setup allows you to:
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Path Prefix Deployments
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
+7 -3
View File
@@ -38,7 +38,7 @@ If detection itself fails (GitHub rate limits, network errors, API outages), syn
## Backup Strategies
Configure via **Settings → GitHub Configuration → Destructive Update Protection**.
Configure via **Configuration → Connections → Destructive Update Protection**.
| Strategy | What It Does | Storage Cost | Best For |
|---|---|---|---|
@@ -78,7 +78,11 @@ These appear when any non-disabled strategy is selected:
### Snapshot Retention Count
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **20**.
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
### Snapshot Retention Days
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
### Snapshot Directory
@@ -96,7 +100,7 @@ The old `backupBeforeSync` boolean is still recognized:
| Old Setting | New Equivalent |
|---|---|
| `backupBeforeSync: true` | `backupStrategy: "always"` |
| `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
| `backupBeforeSync: false` | `backupStrategy: "disabled"` |
| Neither set | `backupStrategy: "on-force-push"` (new default) |
+135
View File
@@ -0,0 +1,135 @@
# Notifications
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
## Supported Providers
### 1. Ntfy.sh (Direct)
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
**Setup (public server):**
1. Go to **Configuration > Notifications**
2. Enable notifications and select **Ntfy.sh** as the provider
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
4. Leave the Server URL as `https://ntfy.sh`
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
**Setup (self-hosted):**
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
3. If authentication is enabled, provide an **Access token**
4. Set your **Topic** name
**Priority levels:**
- `min` / `low` / `default` / `high` / `urgent`
- Error notifications automatically use `high` priority regardless of the default setting
### 2. Apprise API (Aggregator)
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
**Setup:**
1. Deploy the Apprise API server:
```yaml
# docker-compose.yml
services:
apprise:
image: caronc/apprise:latest
ports:
- "8000:8000"
volumes:
- apprise-config:/config
volumes:
apprise-config:
```
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
3. Create a configuration token/key in Apprise
4. In Gitea Mirror, go to **Configuration > Notifications**
5. Enable notifications and select **Apprise API**
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
7. Enter the **Token/path** you created in step 3
**Tag filtering:**
- Optionally set a **Tag** to only notify specific Apprise services
- Leave empty to notify all configured services
### 3. Gotify (Direct)
[Gotify](https://gotify.net/) is a simple self-hosted push notification server.
**Setup:**
1. In Gotify, create an application and copy its token
2. In Gitea Mirror, go to **Configuration > Notifications**
3. Enable notifications and select **Gotify**
4. Set the **Server URL** to your Gotify instance (e.g., `https://gotify.example.com`)
5. Paste the **Application token**
6. Pick a **Default priority** (0 to 10; error notifications are always sent at priority 8)
The token is encrypted at rest like all other provider credentials.
### 4. Webhook (Generic)
Sends a plain JSON POST to any URL you control. Works with n8n, Home Assistant, or any service that accepts a generic webhook.
**Setup:**
1. In Gitea Mirror, go to **Configuration > Notifications**
2. Enable notifications and select **Webhook**
3. Set the **Webhook URL** to your endpoint
4. Optionally set a **Signing secret**
**Payload:**
```json
{
"title": "Mirror Failed: my-repo",
"message": "Repository my-repo failed to mirror",
"type": "sync_error",
"timestamp": "2026-08-03T02:33:29.707Z"
}
```
**Signature verification:**
When a signing secret is set, every request includes an `X-Webhook-Signature` header containing `sha256=<hex>`, an HMAC-SHA256 digest of the raw request body. Verify it on the receiving end:
```js
const crypto = require("node:crypto");
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(signatureHeader), Buffer.from(expected));
```
The secret is encrypted at rest.
## Event Types
| Event | Default | Description |
|-------|---------|-------------|
| Sync errors | On | A mirror job failed |
| Sync success | Off | A mirror job completed successfully |
| New repo discovered | Off | Not yet implemented; the toggle is disabled in the UI |
## Testing
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
## Troubleshooting
**Notifications not arriving:**
- Check that notifications are enabled in the settings
- Verify the provider configuration (URL, topic/token)
- Use the Test button to check connectivity
- Check the server logs for `[NotificationService]` messages
**Ntfy authentication errors:**
- Ensure your access token is correct
- If self-hosting, verify the ntfy server allows the topic
**Apprise connection refused:**
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
- If using Docker, ensure both containers are on the same network
- Check the Apprise server logs for errors
**Tokens and security:**
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
- Tokens are decrypted only when sending notifications or displaying in the settings UI
+96 -6
View File
@@ -4,15 +4,16 @@ This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OI
## Overview
Gitea Mirror supports three authentication methods:
Gitea Mirror supports four 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
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
4. **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.
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
## Setting up SSO (Single Sign-On)
@@ -27,7 +28,7 @@ SSO allows your users to sign in using external identity providers like Google,
#### 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`)
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.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
@@ -56,6 +57,22 @@ https://your-domain.com/api/auth/sso/callback/{provider-id}
Replace `{provider-id}` with your chosen Provider ID.
### Account Linking
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
The trust model is **domain-scoped**:
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
**What this means for you:**
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
### Example: Google SSO Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
@@ -98,9 +115,81 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
Notes:
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
- **Internal / split-DNS IdPs**: since v3.21.0 the SSO sign-in refuses identity providers whose hostnames resolve to private addresses (SSRF hardening in the auth library) unless the IdP's origin is trusted. Providers you register through the UI are trusted automatically, but if you register a *new* provider whose hostname resolves to a LAN IP from inside the container (common with split-horizon DNS), add its origin to `BETTER_AUTH_TRUSTED_ORIGINS` first, e.g. `BETTER_AUTH_TRUSTED_ORIGINS=https://auth.example.com`.
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
## Setting up Header / Forward Authentication
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
### Environment variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
| `HEADER_AUTH_EMAIL_HEADER` | Header containing the email address | `X-Authentik-Email` |
| `HEADER_AUTH_NAME_HEADER` | Header containing the display name | `X-Authentik-Name` |
| `HEADER_AUTH_AUTO_PROVISION` | If `true`, create a new user when an unknown username arrives. If `false`, unknown users are rejected. | `false` |
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist. Empty = allow any. | _(empty)_ |
See also: [`docs/ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md#header-authentication-reverse-proxy-sso).
### Example: Docker Compose with Authentik
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
HEADER_AUTH_ENABLED: "true"
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: "true"
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
```
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
### Example: Authelia
Authelia uses different header names — override them:
```yaml
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_USER_HEADER: "Remote-User"
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
HEADER_AUTH_NAME_HEADER: "Remote-Name"
HEADER_AUTH_AUTO_PROVISION: "true"
```
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
### Behaviour and lookup order
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
- If neither matches:
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
### Verifying it's enabled
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
### Security checklist
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
@@ -185,7 +274,8 @@ When an application requests authentication:
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
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
### OIDC Provider Issues
+50 -2
View File
@@ -125,11 +125,59 @@ npm start
### Debug Mode:
Enable debug logging by setting environment variable:
> **Note:** Better Auth uses its own logger and does **not** read the `DEBUG`
> environment variable (it is not based on the `debug` npm package). An older
> version of this guide suggested `DEBUG=better-auth:*` — that has no effect.
Better Auth's logger defaults to the `warn` level, so SSO/OIDC sign-in and
callback details are hidden. Set the log level to `debug` to surface the full
trace:
```bash
DEBUG=better-auth:* bun run dev
# Local dev
BETTER_AUTH_LOG_LEVEL=debug bun run dev
```
```yaml
# Docker Compose
services:
gitea-mirror:
environment:
- BETTER_AUTH_LOG_LEVEL=debug
```
Then watch the server logs (e.g. `docker compose logs -f gitea-mirror`) while you
attempt an SSO login. Lines are prefixed with `[Better Auth]:`. Accepted values
are `debug`, `info`, `warn`, and `error`.
#### Debugging a login that bounces back to `/login`
If clicking the SSO button sends you to the provider and then straight back to
the login screen, the OAuth flow itself usually succeeded but **no session
cookie was persisted**. Work through these checks:
1. **Enable `BETTER_AUTH_LOG_LEVEL=debug`** (above) and look for errors during
the `/api/auth/sso/callback/<provider-id>` request.
2. **Check for `?error=UNKNOWN` on the landing URL** (or `?error=account%20not%20linked` in dev).
That's Better Auth's account-linking step refusing to attach the SSO identity
to an existing email/password account. The debug log line to look for is
`User already exist but account isn't linked to <providerId>`. The fix is
almost always to set the SSO provider's **Domain** field to the email domain
your users actually have — auto-linking is gated on that domain match.
See [docs/SSO-OIDC-SETUP.md#account-linking](./SSO-OIDC-SETUP.md#account-linking).
3. **Check the redirect URI** registered in your IdP exactly matches
`https://<your-domain>/api/auth/sso/callback/<provider-id>` (scheme, host,
and provider ID — no trailing slash).
4. **Confirm the session cookie is set.** In the browser DevTools → Network,
inspect the callback response for a `Set-Cookie: better-auth-session=…`
header, and DevTools → Application → Cookies for the stored cookie. Behind a
reverse proxy, ensure `BETTER_AUTH_URL` is your **external HTTPS** URL so the
cookie is issued with the correct domain and `Secure` flag, and that the
proxy forwards `X-Forwarded-Proto: https` and `X-Forwarded-Host`.
5. **A `401` on `/api/sso/applications` is unrelated** to client login — that
endpoint backs the OAuth *provider* (consent) management UI and requires an
existing session. It is not part of the Authentik/OIDC sign-in flow.
## Testing Different Scenarios
### 1. New User Registration
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+9
View File
@@ -0,0 +1,9 @@
-- Add index for mirroredLocation lookups (used by name collision detection)
CREATE INDEX IF NOT EXISTS `idx_repositories_mirrored_location` ON `repositories` (`user_id`, `mirrored_location`);
-- Add unique partial index to enforce that no two repos for the same user
-- can claim the same non-empty mirroredLocation. This prevents race conditions
-- during concurrent batch mirroring of starred repos with duplicate names.
CREATE UNIQUE INDEX IF NOT EXISTS `uniq_repositories_user_mirrored_location`
ON `repositories` (`user_id`, `mirrored_location`)
WHERE `mirrored_location` != '';
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
+126
View File
@@ -0,0 +1,126 @@
-- Migrate the OAuth/OIDC *provider* feature from the deprecated
-- `oidc-provider` plugin to `@better-auth/oauth-provider`.
--
-- Tables: oauth_applications -> oauth_clients, oauth_access_tokens reshaped,
-- new oauth_refresh_tokens, oauth_consent -> oauth_consents, plus a `jwks`
-- table for the `jwt` plugin (id_token signing keys).
--
-- Data preservation:
-- * Registered clients are copied from oauth_applications into oauth_clients,
-- converting the legacy comma-separated `redirect_urls` into the JSON
-- string[] (`redirect_uris`) the new adapter expects.
-- * Access tokens and consent records are NOT migrated: access tokens are
-- short-lived (and the column shape changed entirely), and consents are
-- cheaply re-granted on next authorization. Both old tables are dropped.
--
-- NOTE: legacy client secrets were stored in plaintext, whereas the new
-- provider stores them hashed. Migrated client secrets will therefore not
-- validate as-is — affected applications must rotate their secret after
-- upgrade.
CREATE TABLE `oauth_clients` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text,
`name` text,
`disabled` integer DEFAULT false,
`skip_consent` integer,
`enable_end_session` integer,
`subject_type` text,
`scopes` text,
`user_id` text,
`uri` text,
`icon` text,
`contacts` text,
`tos` text,
`policy` text,
`software_id` text,
`software_version` text,
`software_statement` text,
`redirect_uris` text NOT NULL,
`post_logout_redirect_uris` text,
`token_endpoint_auth_method` text,
`grant_types` text,
`response_types` text,
`public` integer,
`type` text,
`require_pkce` integer,
`reference_id` text,
`metadata` text,
`created_at` integer DEFAULT (unixepoch()),
`updated_at` integer DEFAULT (unixepoch()),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_clients_client_id` ON `oauth_clients` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_clients_user_id` ON `oauth_clients` (`user_id`);--> statement-breakpoint
INSERT INTO `oauth_clients` (
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
`redirect_uris`, `type`, `metadata`, `created_at`, `updated_at`
)
SELECT
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
'["' || replace(`redirect_urls`, ',', '","') || '"]',
`type`, `metadata`, `created_at`, `updated_at`
FROM `oauth_applications`;--> statement-breakpoint
DROP TABLE `oauth_applications`;--> statement-breakpoint
DROP TABLE `oauth_access_tokens`;--> statement-breakpoint
CREATE TABLE `oauth_access_tokens` (
`id` text PRIMARY KEY NOT NULL,
`token` text,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text,
`reference_id` text,
`refresh_id` text,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch()),
`scopes` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_access_tokens_token_unique` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_token` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauth_refresh_tokens` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text NOT NULL,
`reference_id` text,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch()),
`revoked` integer,
`auth_time` integer,
`scopes` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_refresh_tokens_token_unique` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_refresh_tokens_token` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_refresh_tokens_client_id` ON `oauth_refresh_tokens` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_refresh_tokens_user_id` ON `oauth_refresh_tokens` (`user_id`);--> statement-breakpoint
DROP TABLE `oauth_consent`;--> statement-breakpoint
CREATE TABLE `oauth_consents` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`user_id` text,
`reference_id` text,
`scopes` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()),
`updated_at` integer DEFAULT (unixepoch()),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_oauth_consents_client_id` ON `oauth_consents` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_consents_user_id` ON `oauth_consents` (`user_id`);--> statement-breakpoint
CREATE TABLE `jwks` (
`id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`expires_at` integer
);
+35
View File
@@ -0,0 +1,35 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_organizations` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`config_id` text NOT NULL,
`name` text NOT NULL,
`normalized_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,
`public_repository_count` integer,
`private_repository_count` integer,
`fork_repository_count` integer,
`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
INSERT INTO `__new_organizations`("id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at") SELECT "id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at" FROM `organizations`;--> statement-breakpoint
DROP TABLE `organizations`;--> statement-breakpoint
ALTER TABLE `__new_organizations` RENAME TO `organizations`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> 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 UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
ALTER TABLE `sso_providers` ADD `saml_config` text;--> statement-breakpoint
ALTER TABLE `sso_providers` ADD `domain_verified` integer DEFAULT true NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `organizations` ADD `mirror_overrides` text;--> statement-breakpoint
ALTER TABLE `repositories` ADD `mirror_overrides` text;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35
View File
@@ -71,6 +71,41 @@
"when": 1773542995732,
"tag": "0009_nervous_tyger_tiger",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1774054800000,
"tag": "0010_mirrored_location_index",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1774058400000,
"tag": "0011_notification_config",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1774062000000,
"tag": "0012_oauth_provider_migration",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1780377747526,
"tag": "0013_slim_galactus",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1787182504975,
"tag": "0014_needy_white_tiger",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -31,7 +31,7 @@
# Build the application
gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror";
version = "3.9.6";
version = "3.14.1";
src = ./.;
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: gitea-mirror
description: Kubernetes helm chart for gitea-mirror
type: application
version: 0.0.1
appVersion: 3.7.2
version: 0.0.2
appVersion: "3.24.0"
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
keywords:
- git
+60 -57
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.13.3",
"version": "3.27.2",
"engines": {
"bun": ">=1.2.9"
},
@@ -31,8 +31,8 @@
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
"preview": "bunx --bun astro preview",
"start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"start": "bun scripts/runtime-server.ts",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
"test": "bun test",
"test:migrations": "bun scripts/validate-migrations.ts",
"test:watch": "bun test --watch",
@@ -45,85 +45,88 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5",
"node-forge": "^1.3.3",
"svgo": "^4.0.1",
"rollup": ">=4.59.0"
"@xmldom/xmldom": "^0.8.13",
"devalue": "^5.8.1",
"fast-uri": "^3.1.2",
"fast-xml-builder": "^1.1.7",
"kysely": "^0.28.17",
"lodash": "^4.18.1",
"picomatch": "^4.0.4"
},
"dependencies": {
"@astrojs/check": "^0.9.7",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.0.1",
"@astrojs/react": "^5.0.0",
"@better-auth/sso": "1.5.5",
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/oauth-provider": "1.6.23",
"@better-auth/sso": "1.6.23",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"@tanstack/react-virtual": "^3.14.6",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.0.4",
"astro": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "1.5.5",
"better-auth": "1.6.23",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fuse.js": "^7.1.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.5.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.6",
"lucide-react": "^1.24.0",
"nanoid": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.5.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"typescript": "^7.0.2",
"uuid": "^14.0.1",
"vaul": "^1.1.2",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.10",
"@types/bun": "^1.3.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0",
"@types/node": "^26.1.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "^0.31.9",
"jsdom": "^28.1.0",
"tsx": "^4.21.0",
"vitest": "^4.1.0"
"@vitejs/plugin-react": "^6.0.3",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"tsx": "^4.23.1",
"vitest": "^4.1.10"
},
"packageManager": "bun@1.3.10"
}
+76
View File
@@ -0,0 +1,76 @@
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
function normalizeBasePath(basePath: string | undefined): string {
if (!basePath || !basePath.trim()) {
return "/";
}
let normalized = basePath.trim();
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
function rewriteRequestUrl(rawUrl: string, basePath: string): string | null {
if (basePath === "/") {
return rawUrl;
}
const url = new URL(rawUrl, "http://localhost");
const pathname = url.pathname;
if (pathname === basePath || pathname === `${basePath}/`) {
url.pathname = "/";
return `${url.pathname}${url.search}`;
}
if (pathname.startsWith(`${basePath}/`)) {
url.pathname = pathname.slice(basePath.length) || "/";
return `${url.pathname}${url.search}`;
}
return null;
}
const basePath = normalizeBasePath(process.env.BASE_URL);
const host = process.env.HOST || "0.0.0.0";
const port = Number.parseInt(process.env.PORT || "4321", 10);
process.env.ASTRO_NODE_AUTOSTART = "disabled";
const { handler } = await import("../dist/server/entry.mjs");
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (!req.url) {
res.statusCode = 400;
res.end("Bad Request");
return;
}
const rewrittenUrl = rewriteRequestUrl(req.url, basePath);
if (rewrittenUrl === null) {
res.statusCode = 404;
res.end("Not Found");
return;
}
req.url = rewrittenUrl;
req.headers["x-gitea-mirror-base-rewritten"] = "1";
Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => {
console.error("Unhandled runtime server error:", error);
if (!res.headersSent) {
res.statusCode = 500;
res.end("Internal Server Error");
} else {
res.end();
}
});
});
server.listen(port, host, () => {
console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`);
});
+21 -1
View File
@@ -13,6 +13,7 @@
*/
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
import { resetStuckMirrorStatuses } from "../src/lib/stuck-status-recovery";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -41,10 +42,29 @@ async function runStartupRecovery() {
}, timeout);
});
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a previous run (issue #339). This must
// happen BEFORE the needsRecovery early-exit below: scheduler-driven
// syncs create no resilient job records, so a crash mid-scheduled-sync
// leaves stuck repo rows but NO interrupted jobs — the early exit would
// skip them forever. The app is not running while this script executes,
// so every in-flight row is an orphan by definition (this script's own
// process start is the cutoff). Never throws.
console.log('Checking for repositories stuck in an in-flight status...');
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`✅ Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) to "failed" for retry.`
);
} else {
console.log('✅ No stuck repository/organization statuses found.');
}
// Check if recovery is needed first
console.log('Checking if recovery is needed...');
const needsRecovery = await hasJobsNeedingRecovery();
if (!needsRecovery) {
console.log('✅ No jobs need recovery. Startup can proceed.');
process.exit(0);
+329 -1
View File
@@ -3,6 +3,10 @@
import { Database } from "bun:sqlite";
import { readFileSync } from "fs";
import path from "path";
import {
repairDuplicateSsoColumns,
restoreSsoDataAfter0013,
} from "../src/lib/db/migration-repairs";
type JournalEntry = {
idx: number;
@@ -143,11 +147,332 @@ function verify0009Migration(db: Database) {
assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration");
}
function seedPre0010Database(db: any) {
// Seed a repo row to verify index creation doesn't break existing data
seedPre0009Database(db);
}
function verify0010Migration(db: any) {
const indexes = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
).all();
if (indexes.length === 0) {
throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location");
}
const lookupIdx = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'"
).all();
if (lookupIdx.length === 0) {
throw new Error("Missing lookup index idx_repositories_mirrored_location");
}
}
function seedPre0011Database(db: any) {
seedPre0009Database(db);
runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!);
runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!);
}
function verify0011Migration(db: any) {
const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[];
const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config");
assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration");
assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL");
assert(
notificationConfigColumn.dflt_value !== null,
"Expected configs.notification_config to have a default value",
);
const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null;
assert(existingConfig, "Expected existing config row to still exist");
const parsed = JSON.parse(existingConfig.notification_config);
assert(parsed.enabled === false, "Expected default notification_config.enabled to be false");
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
}
function seedPre0012Database(db: any) {
// The harness has already run migrations 0000-0011, so the legacy
// oidc-provider tables exist. Seed a registered client (with the legacy
// comma-separated redirect_urls format) plus the related token/consent rows
// to exercise the create/transform/drop paths in 0012.
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
}
function verify0012Migration(db: any) {
// Old provider tables are dropped.
for (const table of ["oauth_applications", "oauth_consent"]) {
const row = db
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
.get(table) as { name: string } | null;
assert(!row, `Expected ${table} table to be dropped after migration`);
}
// New provider tables exist.
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
const row = db
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
.get(table) as { name: string } | null;
assert(row, `Expected ${table} table to exist after migration`);
}
// The registered client is preserved and its redirect URIs converted from
// the legacy comma-separated string into a JSON string[].
const client = db
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
assert(client, "Expected migrated oauth_clients row for app1");
assert(client.client_id === "client-1", "Expected client_id to be preserved");
assert(client.name === "Example App", "Expected client name to be preserved");
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
const uris = JSON.parse(client.redirect_uris);
assert(
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
);
// The reshaped tables accept the new column layout.
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
}
function seedPre0013Database(db: any) {
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
// domainVerified and the organizations table still carries the inherited
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
// and the column-adds can be verified end-to-end.
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
}
function verify0013Migration(db: any) {
// New columns on sso_providers.
const ssoCols = db
.query("PRAGMA table_info(sso_providers)")
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
const saml = ssoCols.find((c) => c.name === "saml_config");
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
assert(saml, "Expected sso_providers.saml_config column to exist");
assert(saml.notnull === 0, "Expected saml_config to be nullable");
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
assert(
domainVerified.dflt_value === "true",
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
);
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
const ssoRow = db
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
// Organizations rebuild preserved the seeded row and dropped the inherited
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
const orgRow = db
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
.get() as { id: string; normalized_name: string } | null;
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
const orgCols = db
.query("PRAGMA table_info(organizations)")
.all() as Array<{ name: string; dflt_value: string | null }>;
const normName = orgCols.find((c) => c.name === "normalized_name");
assert(normName, "Expected organizations.normalized_name column to exist");
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
}
function seedPre0014Database(db: any) {
// Migrations 0000-0013 have run, so repositories/organizations exist but
// lack mirror_overrides. Seed one of each so the column-add can be verified
// against pre-existing rows (they must come out NULL = "inherit").
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-ovr', 'ovr@example.com', 'ovr', 'Override User')");
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre14', 'u-ovr', 'Default', 1, '{}', '{}', '{}', '{}')");
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre14', 'u-ovr', 'cfg-pre14', 'ExampleOrg', 'https://example.com/a.png', 'exampleorg')");
db.run(
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
"VALUES ('repo-pre14', 'u-ovr', 'cfg-pre14', 'browser-use', 'ExampleOrg/browser-use', 'exampleorg/browser-use', 'https://github.com/ExampleOrg/browser-use', 'https://github.com/ExampleOrg/browser-use.git', 'ExampleOrg', 'main')",
);
}
function verify0014Migration(db: any) {
// Both tables gained a nullable mirror_overrides column with no default.
for (const table of ["repositories", "organizations"]) {
const cols = db
.query(`PRAGMA table_info(${table})`)
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>;
const col = cols.find((c) => c.name === "mirror_overrides");
assert(col, `Expected ${table}.mirror_overrides column to exist`);
assert(col.notnull === 0, `Expected ${table}.mirror_overrides to be nullable`);
assert(
col.dflt_value === null,
`Expected ${table}.mirror_overrides to have no default, got ${col.dflt_value}`,
);
}
// Pre-existing rows inherit (NULL), rather than being backfilled with {}.
const repoRow = db
.query("SELECT id, mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
.get() as { id: string; mirror_overrides: string | null } | null;
assert(repoRow, "Expected pre-existing repository row to survive migration");
assert(
repoRow.mirror_overrides === null,
`Expected existing repository to inherit (NULL mirror_overrides), got ${repoRow.mirror_overrides}`,
);
const orgRow = db
.query("SELECT id, mirror_overrides FROM organizations WHERE id = 'org-pre14'")
.get() as { id: string; mirror_overrides: string | null } | null;
assert(orgRow, "Expected pre-existing organization row to survive migration");
assert(
orgRow.mirror_overrides === null,
`Expected existing organization to inherit (NULL mirror_overrides), got ${orgRow.mirror_overrides}`,
);
// The new column round-trips a JSON overrides payload (the #361 case:
// mirror this repo but skip its LFS objects).
db.run(
"UPDATE repositories SET mirror_overrides = '{\"lfs\":false}' WHERE id = 'repo-pre14'",
);
const updated = db
.query("SELECT mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
.get() as { mirror_overrides: string } | null;
assert(updated, "Expected repository row after override update");
const parsed = JSON.parse(updated.mirror_overrides);
assert(parsed.lfs === false, `Expected persisted lfs override false, got ${updated.mirror_overrides}`);
}
const MIGRATION_0012_TIMESTAMP = 1774062000000;
const MIGRATION_0013_TIMESTAMP = 1780377747526;
/**
* Reproduce the issue #312 crash state — sso_providers already carries
* saml_config / domain_verified before migration 0013 runs (stranded on an
* intermediate build), with __drizzle_migrations recorded only through 0012 —
* and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the
* canonical 0013 run while preserving real SAML provider data.
*/
function validateBroken0013Repair() {
const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus");
if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test.
const db = new Database(":memory:");
try {
runMigrations(db, migrations.slice(0, 13)); // 0000-0012
// A real upgraded instance has a __drizzle_migrations table recorded
// through 0012 but not 0013.
db.run(
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
);
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [
MIGRATION_0012_TIMESTAMP,
]);
// Stranded columns from the intermediate build.
db.run("ALTER TABLE sso_providers ADD saml_config text");
db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL");
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')");
const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}';
db.run(
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)",
);
db.run(
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)",
[samlJson],
);
db.run(
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)",
);
const preserved = repairDuplicateSsoColumns(db);
const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map(
(c) => c.name,
);
assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column");
assert(
!colsAfterRepair.includes("domain_verified"),
"Expected repair to drop stranded domain_verified column",
);
const preservedIds = preserved.map((r) => r.id).sort();
assert(
preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1",
`Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`,
);
// The canonical 0013 must now run without a duplicate-column error.
runMigration(db, migration0013);
restoreSsoDataAfter0013(db, preserved);
const rows = db
.query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id")
.all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>;
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL");
assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1");
assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved");
assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1");
assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL");
assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved");
// Idempotency: 0013 is now applied, so a re-run of the repair is a no-op.
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [
MIGRATION_0013_TIMESTAMP,
]);
const secondPass = repairDuplicateSsoColumns(db);
assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded");
const colsAfterSecondPass = (
db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]
).map((c) => c.name);
assert(
colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"),
"Expected columns to remain intact on the no-op second pass",
);
} finally {
db.close();
}
}
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
"0009_nervous_tyger_tiger": {
seed: seedPre0009Database,
verify: verify0009Migration,
},
"0010_mirrored_location_index": {
seed: seedPre0010Database,
verify: verify0010Migration,
},
"0011_notification_config": {
seed: seedPre0011Database,
verify: verify0011Migration,
},
"0012_oauth_provider_migration": {
seed: seedPre0012Database,
verify: verify0012Migration,
},
"0013_slim_galactus": {
seed: seedPre0013Database,
verify: verify0013Migration,
},
"0014_needy_white_tiger": {
seed: seedPre0014Database,
verify: verify0014Migration,
},
};
function lintMigrations(selectedMigrations: Migration[]) {
@@ -198,8 +523,11 @@ function validateMigrations() {
upgradeDb.close();
}
// Exercise the runtime repair for the issue #312 duplicate-column crash.
validateBroken0013Repair();
console.log(
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`,
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair.`,
);
}
+6 -5
View File
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
import { withBase } from "@/lib/base-path";
export function NotFound() {
return (
@@ -21,7 +22,7 @@ export function NotFound() {
{/* Action Buttons */}
<div className="flex flex-col gap-3">
<Button asChild className="w-full">
<a href="/">
<a href={withBase("/")}>
<Home className="mr-2 h-4 w-4" />
Go to Dashboard
</a>
@@ -45,21 +46,21 @@ export function NotFound() {
{/* Quick Links */}
<div className="grid grid-cols-3 gap-3">
<a
href="/repositories"
href={withBase("/repositories")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<GitBranch className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Repositories</span>
</a>
<a
href="/config"
href={withBase("/config")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Config</span>
</a>
<a
href="/docs"
href="https://gitea-mirror.raylabs.io/docs/"
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<BookOpen className="h-5 w-5 text-muted-foreground" />
@@ -77,4 +78,4 @@ export function NotFound() {
</Card>
</div>
);
}
}
+4
View File
@@ -6,6 +6,7 @@ import { Button } from '../ui/button';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { useTimeFormat } from '@/hooks/useTimeFormat';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
@@ -32,6 +33,9 @@ export default function ActivityList({
filter,
setFilter,
}: ActivityListProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [expandedItems, setExpandedItems] = useState<Set<string>>(
() => new Set(),
);
+99 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter, StopCircle } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +36,7 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout';
import { withBase } from '@/lib/base-path';
import {
Drawer,
DrawerClose,
@@ -84,6 +85,8 @@ export function ActivityLog() {
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
const [isInitialLoading, setIsInitialLoading] = useState(false);
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
const [showCancelPendingDialog, setShowCancelPendingDialog] = useState(false);
const [isCancelPendingLoading, setIsCancelPendingLoading] = useState(false);
// Ref to track if component is mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
@@ -321,7 +324,7 @@ export function ActivityLog() {
setIsInitialLoading(true);
setShowCleanupDialog(false);
const response = await fetch('/api/activities/cleanup', {
const response = await fetch(withBase('/api/activities/cleanup'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
@@ -353,6 +356,40 @@ export function ActivityLog() {
setShowCleanupDialog(false);
};
const confirmCancelPending = async () => {
if (!user?.id) return;
try {
setIsCancelPendingLoading(true);
setShowCancelPendingDialog(false);
const response = await fetch(withBase('/api/job/cancel-pending'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
const res = await response.json();
if (res.success) {
toast.success(res.message);
// Refresh to show the new activity log entry
await fetchActivities(false);
} else {
showErrorToast(res.error || 'Failed to cancel pending mirrors.', toast);
}
} catch (error) {
console.error('Error cancelling pending mirrors:', error);
showErrorToast(error, toast);
} finally {
setIsCancelPendingLoading(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;
@@ -551,11 +588,22 @@ export function ActivityLog() {
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setShowCancelPendingDialog(true)}
title="Stop pending mirrors"
className="text-amber-600 hover:text-amber-600 h-10 w-10 shrink-0"
disabled={isCancelPendingLoading}
>
<StopCircle className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
title="Clear activity history"
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
>
<Trash2 className="h-4 w-4" />
@@ -682,12 +730,24 @@ export function ActivityLog() {
<RefreshCw className="h-4 w-4" />
</Button>
{/* cleanup all activities */}
{/* stop pending mirrors */}
<Button
variant="outline"
size="icon"
onClick={() => setShowCancelPendingDialog(true)}
title="Stop pending mirrors"
className="text-amber-600 hover:text-amber-600 h-10 w-10"
disabled={isCancelPendingLoading}
>
<StopCircle className="h-4 w-4" />
</Button>
{/* clear activity history */}
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
title="Clear activity history"
className="text-destructive hover:text-destructive h-10 w-10"
>
<Trash2 className="h-4 w-4" />
@@ -708,9 +768,12 @@ export function ActivityLog() {
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete All Activities</DialogTitle>
<DialogTitle>Clear Activity History</DialogTitle>
<DialogDescription>
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
This clears the activity <strong>history log</strong> (mirror job records and events) — it does not stop
any pending or in-progress mirrors. Repositories keep their current status and the scheduler
will continue to pick up pending work. To stop pending mirrors, use the
&ldquo;Stop Pending Mirrors&rdquo; button instead.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -722,7 +785,35 @@ export function ActivityLog() {
onClick={confirmCleanup}
disabled={isInitialLoading}
>
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
{isInitialLoading ? 'Clearing...' : 'Clear History'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* cancel pending mirrors confirmation dialog */}
<Dialog open={showCancelPendingDialog} onOpenChange={setShowCancelPendingDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Stop Pending Mirrors</DialogTitle>
<DialogDescription>
This sets all repositories with status <strong>Imported</strong> or <strong>Failed</strong> to{' '}
<strong>Ignored</strong>, preventing the scheduler from mirroring them automatically.
Repositories that are currently mirroring are not affected.{' '}
You can re-enable individual repositories from the Repositories page.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCancelPendingDialog(false)}>
Cancel
</Button>
<Button
variant="default"
className="bg-amber-600 hover:bg-amber-700"
onClick={confirmCancelPending}
disabled={isCancelPendingLoading}
>
{isCancelPendingLoading ? 'Stopping...' : 'Stop Pending Mirrors'}
</Button>
</DialogFooter>
</DialogContent>
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { KeyRound, LogOut, Mail } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authClient } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChangePasswordDialog } from "./ChangePasswordDialog";
import { ChangeEmailDialog } from "./ChangeEmailDialog";
export function AccountMenu() {
const { user, logout, refreshUser } = useAuth();
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
const [passwordOpen, setPasswordOpen] = useState(false);
const [emailOpen, setEmailOpen] = useState(false);
useEffect(() => {
if (!user) {
setHasPassword(null);
return;
}
let cancelled = false;
(async () => {
try {
const accounts = await authClient.listAccounts();
if (cancelled) return;
const list = Array.isArray(accounts) ? accounts : accounts?.data;
setHasPassword(
Array.isArray(list) && list.some((a) => a.providerId === "credential")
);
} catch {
// Fail open: if we can't check, show the option rather than locking the
// user out of changing their password.
if (!cancelled) setHasPassword(true);
}
})();
return () => {
cancelled = true;
};
}, [user?.id]);
if (!user) {
return (
<Button variant="outline" size="sm" asChild>
<a href={withBase("/login")}>Login</a>
</Button>
);
}
const handleLogout = async () => {
toast.success("Logged out successfully");
await new Promise((resolve) => setTimeout(resolve, 500));
logout();
};
return (
<>
<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-60">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
{user.name && (
<span className="text-sm font-medium leading-none">
{user.name}
</span>
)}
<span className="text-xs leading-none text-muted-foreground truncate">
{user.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{hasPassword && (
<DropdownMenuItem
onSelect={() => setPasswordOpen(true)}
className="cursor-pointer"
>
<KeyRound className="h-4 w-4 mr-2" />
Change password
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => setEmailOpen(true)}
className="cursor-pointer"
>
<Mail className="h-4 w-4 mr-2" />
Change email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleLogout} className="cursor-pointer">
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasPassword && (
<ChangePasswordDialog
open={passwordOpen}
onOpenChange={setPasswordOpen}
/>
)}
<ChangeEmailDialog
open={emailOpen}
onOpenChange={setEmailOpen}
currentEmail={user.email}
onUpdated={refreshUser}
/>
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ChangeEmailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentEmail: string;
onUpdated?: () => void;
}
export function ChangeEmailDialog({
open,
onOpenChange,
currentEmail,
onUpdated,
}: ChangeEmailDialogProps) {
const [newEmail, setNewEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const reset = () => {
setNewEmail("");
setIsSubmitting(false);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = newEmail.trim();
if (!trimmed) {
toast.error("Please enter a new email");
return;
}
if (trimmed.toLowerCase() === currentEmail.toLowerCase()) {
toast.error("New email must differ from current email");
return;
}
setIsSubmitting(true);
try {
const { error } = await authClient.changeEmail({ newEmail: trimmed });
if (error) {
toast.error(error.message || "Failed to change email");
return;
}
toast.success("Email updated.");
onUpdated?.();
handleOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to change email");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change email</DialogTitle>
<DialogDescription>
Current: <span className="font-medium">{currentEmail}</span>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-email">New email</Label>
<Input
id="new-email"
type="email"
autoComplete="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update email"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,158 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ChangePasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const reset = () => {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setRevokeOtherSessions(true);
setIsSubmitting(false);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword) {
toast.error("Please fill in all fields");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New passwords do not match");
return;
}
if (newPassword === currentPassword) {
toast.error("New password must differ from current password");
return;
}
setIsSubmitting(true);
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions,
});
if (error) {
toast.error(error.message || "Failed to change password");
return;
}
toast.success(
revokeOtherSessions
? "Password updated. Other sessions signed out."
: "Password updated."
);
handleOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to change password");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
Enter your current password and a new one. You'll stay signed in on this device.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isSubmitting}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isSubmitting}
required
minLength={8}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
disabled={isSubmitting}
/>
<Label htmlFor="revoke-sessions" className="text-sm font-normal cursor-pointer">
Sign out other devices
</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update password"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+18 -5
View File
@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function LoginForm() {
@@ -47,7 +48,7 @@ export function LoginForm() {
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
window.location.href = withBase('/');
}, 1000);
} catch (error) {
showErrorToast(error, toast);
@@ -64,14 +65,26 @@ export function LoginForm() {
return;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
await authClient.signIn.sso({
const callbackURL =
typeof window !== 'undefined'
? new URL(withBase('/'), window.location.origin).toString()
: `http://localhost:4321${withBase('/')}`;
const result = await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
providerId: providerId,
callbackURL: `${baseURL}/`,
callbackURL,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
});
// The auth client resolves with { data, error } instead of throwing, so
// a server-side rejection (e.g. the SSO plugin refusing an IdP whose
// hostname resolves to a private address) never reaches the catch below.
if (result?.error) {
showErrorToast(
new Error(result.error.message || `SSO sign-in failed (HTTP ${result.error.status})`),
toast
);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -85,7 +98,7 @@ export function LoginForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-8 w-10"
/>
+3 -2
View File
@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false);
@@ -42,7 +43,7 @@ export function SignupForm() {
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
window.location.href = withBase('/');
}, 1500);
} catch (error) {
showErrorToast(error, toast);
@@ -57,7 +58,7 @@ export function SignupForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-8 w-10"
/>
+317 -318
View File
@@ -1,7 +1,5 @@
import { useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -9,25 +7,37 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Clock,
Database,
RefreshCw,
Calendar,
Activity,
Zap,
Info,
Archive,
ArchiveRestore,
CircleOff,
Globe,
History,
Info,
Trash2,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
buildClockCronExpression,
getNextCronOccurrence,
} from "@/lib/utils/schedule-utils";
import {
SettingsCard,
SectionTitle,
SwitchRow,
OptionTile,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig;
@@ -38,15 +48,13 @@ interface AutomationSettingsProps {
isAutoSavingCleanup?: boolean;
}
const scheduleIntervals = [
{ label: "Every hour", value: 3600 },
{ label: "Every 2 hours", value: 7200 },
{ label: "Every 4 hours", value: 14400 },
{ label: "Every 8 hours", value: 28800 },
{ label: "Every 12 hours", value: 43200 },
{ label: "Daily", value: 86400 },
{ label: "Every 2 days", value: 172800 },
{ label: "Weekly", value: 604800 },
const clockFrequencies = [
{ label: "Every hour", value: 1 },
{ label: "Every 2 hours", value: 2 },
{ label: "Every 4 hours", value: 4 },
{ label: "Every 8 hours", value: 8 },
{ label: "Every 12 hours", value: 12 },
{ label: "Daily", value: 24 },
];
const retentionPeriods = [
@@ -77,6 +85,30 @@ function getCleanupFrequencyText(retentionSeconds: number): string {
return "weekly";
}
const orphanActions = [
{
value: "skip" as const,
label: "Skip",
description: "Leave the mirror untouched",
icon: CircleOff,
info: "Orphaned mirrors stay exactly as they are and keep their sync settings.",
},
{
value: "archive" as const,
label: "Archive",
description: "Kept read-only with an archived prefix",
icon: Archive,
info: "Renames the mirror with an archived- prefix and disables automatic syncs. Nothing is lost; use Manual Sync to refresh.",
},
{
value: "delete" as const,
label: "Delete",
description: "Remove the mirror from Gitea",
icon: Trash2,
info: "Permanently deletes the mirror repository from Gitea.",
},
];
export function AutomationSettings({
scheduleConfig,
cleanupConfig,
@@ -85,6 +117,35 @@ export function AutomationSettings({
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
: "UTC";
// Use saved timezone, but treat "UTC" as unset for users who never chose
// it: older versions stored UTC as a default without asking. Anyone truly
// in UTC gets the same result via their browser timezone.
const effectiveTimezone =
scheduleConfig.timezone && scheduleConfig.timezone !== "UTC"
? scheduleConfig.timezone
: browserTimezone;
const nextScheduledRun = useMemo(() => {
if (!scheduleConfig.enabled) return null;
const startTime = scheduleConfig.startTime || "22:00";
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
if (!cronExpression) return null;
try {
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
} catch {
return null;
}
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
// Update nextRun for cleanup when settings change
useEffect(() => {
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
@@ -94,91 +155,101 @@ export function AutomationSettings({
}
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
const savingSpinner = (saving?: boolean) =>
saving ? (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
) : undefined;
return (
<Card className="w-full">
<CardHeader>
<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>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Syncing Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" />
Automatic Syncing
</h3>
{isAutoSavingSchedule && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-mirror"
checked={scheduleConfig.enabled}
className="mt-1.25"
onCheckedChange={(checked) =>
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 items-stretch gap-6 md:grid-cols-2">
{/* Automatic Syncing */}
<SettingsCard
icon={RefreshCw}
title="Automatic Syncing"
enabled={scheduleConfig.enabled}
onEnabledChange={(checked) =>
onScheduleChange({
...scheduleConfig,
enabled: checked,
timezone: checked ? browserTimezone : scheduleConfig.timezone,
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
scheduleMode: "clock",
})
}
headerAction={savingSpinner(isAutoSavingSchedule)}
footer={
<>
<StatusFooterItem
icon={History}
label="Last sync"
value={
scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"
}
/>
{scheduleConfig.enabled ? (
<StatusFooterItem
icon={Calendar}
label="Next sync"
value={
scheduleConfig.nextRun
? formatDate(scheduleConfig.nextRun)
: nextScheduledRun
? formatDate(nextScheduledRun)
: "Calculating..."
}
valueClassName="text-indigo-500"
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-mirror"
className="text-sm font-normal cursor-pointer"
>
Enable automatic repository syncing
</Label>
<p className="text-xs text-muted-foreground">
Periodically check GitHub for changes and mirror them to Gitea
</p>
</div>
</div>
) : (
<span className="text-xs text-muted-foreground/70">
Enable syncing to schedule updates
</span>
)}
</>
}
>
{scheduleConfig.enabled && (
<>
<CardSection>
<SectionTitle
action={
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
<Globe className="h-3 w-3" />
{effectiveTimezone}
</span>
}
>
Schedule
</SectionTitle>
{scheduleConfig.enabled && (
<div className="ml-6 space-y-3">
<div>
<Label htmlFor="mirror-interval" className="text-sm">
Sync frequency
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label
htmlFor="clock-frequency"
className="text-xs font-medium text-muted-foreground"
>
Frequency
</Label>
<Select
value={scheduleConfig.interval.toString()}
value={String(scheduleConfig.clockFrequencyHours || 24)}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
interval: parseInt(value, 10),
scheduleMode: "clock",
clockFrequencyHours: parseInt(value, 10),
startTime: scheduleConfig.startTime || "22:00",
timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="mirror-interval" className="mt-1.5">
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scheduleIntervals.map((option) => (
{clockFrequencies.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
@@ -189,258 +260,186 @@ export function AutomationSettings({
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last sync
</span>
<span className="font-medium text-muted-foreground">
{scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"}
</span>
</div>
{scheduleConfig.enabled ? (
scheduleConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync
</span>
<span className="font-medium">
{formatDate(scheduleConfig.nextRun)}
</span>
</div>
)
) : (
<div className="text-xs text-muted-foreground">
Enable automatic syncing to schedule periodic repository updates
</div>
)}
</div>
</div>
</div>
{/* Database Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
Database Maintenance
</h3>
{isAutoSavingCleanup && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-cleanup"
checked={cleanupConfig.enabled}
className="mt-1.25"
onCheckedChange={(checked) =>
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-cleanup"
className="text-sm font-normal cursor-pointer"
>
Enable automatic database cleanup
</Label>
<p className="text-xs text-muted-foreground">
Remove old activity logs and events to optimize storage
</p>
</div>
</div>
{cleanupConfig.enabled && (
<div className="ml-6 space-y-5">
<div className="space-y-2">
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Activity logs and events older than this will be removed.
Cleanup frequency is automatically optimized based on your retention period.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="space-y-1.5">
<Label
htmlFor="clock-start-time"
className="text-xs font-medium text-muted-foreground"
>
Start time
</Label>
<div className="flex items-center gap-3 mt-1.5">
<Select
value={cleanupConfig.retentionDays.toString()}
onValueChange={(value) =>
onCleanupChange({
...cleanupConfig,
retentionDays: parseInt(value, 10),
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Clock className="size-4" />
</div>
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
startTime: event.target.value,
clockFrequencyHours:
scheduleConfig.clockFrequencyHours || 24,
timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="retention-period" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{retentionPeriods.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
</p>
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last cleanup
</span>
<span className="font-medium text-muted-foreground">
{cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"}
</span>
</div>
{cleanupConfig.enabled ? (
cleanupConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next cleanup
</span>
<span className="font-medium">
{formatDate(cleanupConfig.nextRun)}
</span>
</div>
)
) : (
<div className="text-xs text-muted-foreground">
Enable automatic cleanup to optimize database storage
</div>
)}
</div>
</div>
</div>
{/* Repository Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50 md:col-span-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Archive className="h-4 w-4 text-primary" />
Repository Cleanup (orphaned mirrors)
</h3>
{isAutoSavingCleanup && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Auto-mirror new repositories"
description="Mirror repos discovered during sync automatically"
info="When off, newly discovered repos are imported for browsing and wait for a manual mirror click. Starred repos have their own toggle in GitHub settings."
checked={scheduleConfig.autoMirror ?? false}
onCheckedChange={(checked) =>
onScheduleChange({ ...scheduleConfig, autoMirror: checked })
}
/>
</CardSection>
</>
)}
</div>
</SettingsCard>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="cleanup-handle-orphans"
checked={Boolean(cleanupConfig.deleteIfNotInGitHub)}
className="mt-1.25"
onCheckedChange={(checked) =>
onCleanupChange({
...cleanupConfig,
deleteIfNotInGitHub: Boolean(checked),
})
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="cleanup-handle-orphans"
className="text-sm font-normal cursor-pointer"
>
Handle orphaned repositories automatically
</Label>
<p className="text-xs text-muted-foreground">
Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs.
</p>
</div>
</div>
{cleanupConfig.deleteIfNotInGitHub && (
<div className="space-y-3 ml-6">
<div className="space-y-1">
<Label htmlFor="cleanup-orphaned-action" className="text-sm font-medium">
Action for orphaned repositories
</Label>
{/* Database Maintenance */}
<SettingsCard
icon={Database}
title="Database Maintenance"
enabled={cleanupConfig.enabled}
onEnabledChange={(checked) =>
onCleanupChange({ ...cleanupConfig, enabled: checked })
}
headerAction={savingSpinner(isAutoSavingCleanup)}
className="md:h-full"
footer={
<>
<StatusFooterItem
icon={History}
label="Last cleanup"
value={
cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"
}
/>
{cleanupConfig.enabled && cleanupConfig.nextRun ? (
<StatusFooterItem
icon={Calendar}
label="Next cleanup"
value={formatDate(cleanupConfig.nextRun)}
valueClassName="text-indigo-500"
/>
) : (
<span className="text-xs text-muted-foreground/70">
Old activity logs are removed automatically
</span>
)}
</>
}
>
{cleanupConfig.enabled && (
<CardSection>
<SectionTitle>Data retention</SectionTitle>
<div className="flex items-center gap-3">
<Select
value={cleanupConfig.orphanedRepoAction ?? "archive"}
value={cleanupConfig.retentionDays.toString()}
onValueChange={(value) =>
onCleanupChange({
...cleanupConfig,
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
retentionDays: parseInt(value, 10),
})
}
>
<SelectTrigger id="cleanup-orphaned-action">
<SelectTrigger id="retention-period" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="archive">Archive (preserve data)</SelectItem>
<SelectItem value="skip">Skip (leave as-is)</SelectItem>
<SelectItem value="delete">Delete from Gitea</SelectItem>
{retentionPeriods.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Archive renames mirror backups with an <code>archived-</code> prefix and disables automatic syncs—use Manual Sync when you want to refresh.
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="cleanup-dry-run"
className="text-sm font-normal cursor-pointer"
>
Dry run (log only)
</Label>
<p className="text-xs text-muted-foreground max-w-xl">
When enabled, cleanup logs the planned action without modifying repositories.
</p>
</div>
<Switch
id="cleanup-dry-run"
checked={Boolean(cleanupConfig.dryRun)}
onCheckedChange={(checked) =>
onCleanupChange({
...cleanupConfig,
dryRun: Boolean(checked),
})
}
/>
</div>
</div>
<p className="max-w-md text-[13px] leading-relaxed text-muted-foreground">
Activity logs and events older than the retention period are
removed. Cleanup frequency adapts to the period you pick.
</p>
</CardSection>
)}
</div>
</SettingsCard>
</div>
{/* Repository Cleanup */}
<SettingsCard
icon={ArchiveRestore}
title="Repository Cleanup"
enabled={Boolean(cleanupConfig.deleteIfNotInGitHub)}
onEnabledChange={(checked) =>
onCleanupChange({ ...cleanupConfig, deleteIfNotInGitHub: checked })
}
headerAction={savingSpinner(isAutoSavingCleanup)}
footer={
<StatusFooterItem
icon={Info}
label="Runs as part of each scheduled sync"
/>
}
>
{cleanupConfig.deleteIfNotInGitHub && (
<>
<CardSection>
<SectionTitle>When a GitHub repo is deleted</SectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{orphanActions.map((action) => (
<OptionTile
key={action.value}
icon={action.icon}
label={action.label}
description={action.description}
info={action.info}
selected={
(cleanupConfig.orphanedRepoAction ?? "archive") ===
action.value
}
onSelect={() =>
onCleanupChange({
...cleanupConfig,
orphanedRepoAction: action.value,
})
}
/>
))}
</div>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Dry run"
description="Log planned actions without changing anything"
checked={Boolean(cleanupConfig.dryRun)}
onCheckedChange={(checked) =>
onCleanupChange({ ...cleanupConfig, dryRun: checked })
}
/>
</CardSection>
</>
)}
</SettingsCard>
</div>
</CardContent>
</Card>
);
}
+156 -44
View File
@@ -1,8 +1,10 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { GitHubMirrorSettings } from './GitHubMirrorSettings';
import { AutomationSettings } from './AutomationSettings';
import { SSOSettings } from './SSOSettings';
import { NotificationSettings } from './NotificationSettings';
import type {
ConfigApiResponse,
GiteaConfig,
@@ -13,6 +15,7 @@ import type {
DatabaseCleanupConfig,
MirrorOptions,
AdvancedOptions,
NotificationConfig,
} from '@/types/config';
import { Button } from '../ui/button';
import { useAuth } from '@/hooks/useAuth';
@@ -22,6 +25,7 @@ import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { withBase } from '@/lib/base-path';
type ConfigState = {
githubConfig: GitHubConfig;
@@ -30,8 +34,11 @@ type ConfigState = {
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
notificationConfig: NotificationConfig;
};
const CONFIG_API_PATH = withBase('/api/config');
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
@@ -39,6 +46,7 @@ export function ConfigTabs() {
token: '',
privateRepositories: false,
mirrorStarred: false,
starredLists: [],
},
giteaConfig: {
url: '',
@@ -51,7 +59,8 @@ export function ConfigTabs() {
starredReposMode: 'dedicated-org',
preserveOrgStructure: false,
backupStrategy: "on-force-push",
backupRetentionCount: 20,
backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: 'data/repo-backups',
blockSyncOnBackupFailure: true,
},
@@ -85,6 +94,13 @@ export function ConfigTabs() {
starredCodeOnly: false,
autoMirrorStarred: false,
},
notificationConfig: {
enabled: false,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: false,
notifyOnNewRepo: false,
},
});
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
@@ -94,10 +110,12 @@ export function ConfigTabs() {
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
const [isAutoSavingNotification, setIsAutoSavingNotification] = useState<boolean>(false);
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveNotificationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config;
@@ -184,7 +202,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -250,7 +268,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -315,7 +333,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -364,7 +382,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -404,7 +422,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -439,7 +457,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -459,6 +477,55 @@ export function ConfigTabs() {
}
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
// Auto-save function for notification config changes
const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => {
if (!user?.id) return;
// Clear any existing timeout
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
// Debounce the auto-save to prevent excessive API calls
autoSaveNotificationTimeoutRef.current = setTimeout(async () => {
setIsAutoSavingNotification(true);
const reqPayload = {
userId: user.id!,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
notificationConfig: notifConfig,
};
try {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
// Silent success - no toast for auto-save
invalidateConfigCache();
} else {
showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`,
toast
);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsAutoSavingNotification(false);
}
}, 500); // 500ms debounce
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -474,6 +541,9 @@ export function ConfigTabs() {
if (autoSaveGiteaTimeoutRef.current) {
clearTimeout(autoSaveGiteaTimeoutRef.current);
}
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
};
}, []);
@@ -505,6 +575,8 @@ export function ConfigTabs() {
},
advancedOptions:
response.advancedOptions || config.advancedOptions,
notificationConfig:
(response as any).notificationConfig || config.notificationConfig,
});
}
@@ -615,7 +687,7 @@ export function ConfigTabs() {
? 'Import in progress'
: 'Import GitHub Data'
}
className="w-full md:w-auto"
className="w-full bg-indigo-500 text-white hover:bg-indigo-600 disabled:bg-muted disabled:text-muted-foreground md:w-auto"
>
{isSyncing ? (
<>
@@ -634,77 +706,106 @@ export function ConfigTabs() {
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
<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 =>
<TabsContent value="connections" className="space-y-6">
{(() => {
const githubFormProps = {
config: config.githubConfig,
setConfig: (update: React.SetStateAction<typeof config.githubConfig>) =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
})),
mirrorOptions: config.mirrorOptions,
setMirrorOptions: (update: React.SetStateAction<typeof config.mirrorOptions>) =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
})),
advancedOptions: config.advancedOptions,
setAdvancedOptions: (update: React.SetStateAction<typeof config.advancedOptions>) =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
giteaConfig={config.giteaConfig}
setGiteaConfig={update =>
})),
giteaConfig: config.giteaConfig,
setGiteaConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
onGiteaAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
})),
onAutoSave: autoSaveGitHubConfig,
onMirrorOptionsAutoSave: autoSaveMirrorOptions,
onAdvancedOptionsAutoSave: autoSaveAdvancedOptions,
onGiteaAutoSave: autoSaveGiteaConfig,
isAutoSaving: isAutoSavingGitHub,
};
const giteaFormProps = {
config: config.giteaConfig,
setConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
})),
onAutoSave: autoSaveGiteaConfig,
isAutoSaving: isAutoSavingGitea,
githubUsername: config.githubConfig.username,
};
return (
<>
{/* Connection cards share a stretched row so they stay equal height */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<GitHubConfigForm {...githubFormProps} part="connection" />
<GiteaConfigForm {...giteaFormProps} part="connection" />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:items-start">
<GitHubConfigForm {...githubFormProps} part="settings" />
<div className="flex flex-col gap-6">
<GiteaConfigForm {...giteaFormProps} part="organization" />
<GitHubMirrorSettings
part="content"
githubConfig={config.githubConfig}
mirrorOptions={config.mirrorOptions}
advancedOptions={config.advancedOptions}
onGitHubConfigChange={newConfig => {
setConfig(prev => ({ ...prev, githubConfig: newConfig }));
autoSaveGitHubConfig(newConfig);
}}
onMirrorOptionsChange={newOptions => {
setConfig(prev => ({ ...prev, mirrorOptions: newOptions }));
autoSaveMirrorOptions(newOptions);
}}
onAdvancedOptionsChange={newOptions => {
setConfig(prev => ({ ...prev, advancedOptions: newOptions }));
autoSaveAdvancedOptions(newOptions);
}}
/>
</div>
</div>
</>
);
})()}
</TabsContent>
<TabsContent value="automation" className="space-y-4">
@@ -724,6 +825,17 @@ export function ConfigTabs() {
/>
</TabsContent>
<TabsContent value="notifications" className="space-y-4">
<NotificationSettings
notificationConfig={config.notificationConfig}
onNotificationChange={(newConfig) => {
setConfig(prev => ({ ...prev, notificationConfig: newConfig }));
autoSaveNotificationConfig(newConfig);
}}
isAutoSaving={isAutoSavingNotification}
/>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
+301 -270
View File
@@ -1,24 +1,33 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { githubApi } from "@/lib/api";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, GiteaConfig, BackupStrategy } from "@/types/config";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { toast } from "sonner";
import { Info, ShieldAlert } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
import { Separator } from "../ui/separator";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
Activity,
CircleOff,
DatabaseBackup,
ExternalLink,
Hand,
KeyRound,
PlugZap,
ShieldAlert,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { SiGithub } from "react-icons/si";
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
import {
SettingsCard,
SectionTitle,
SwitchRow,
OptionTile,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface GitHubConfigFormProps {
config: GitHubConfig;
@@ -34,9 +43,43 @@ interface GitHubConfigFormProps {
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
onGiteaAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
isAutoSaving?: boolean;
/** Which card group to render: the connection card, or the settings stack
* (repository selection + destructive update protection). */
part?: "connection" | "settings";
}
export function GitHubConfigForm({
const backupStrategies = [
{
value: "disabled" as const,
label: "Disabled",
description: "Force-pushes sync straight through",
icon: CircleOff,
info: "No detection or backups. Rewritten upstream history overwrites your mirror.",
},
{
value: "always" as const,
label: "Always Backup",
description: "Snapshot before every sync",
icon: DatabaseBackup,
info: "Maximum safety at the cost of disk usage.",
},
{
value: "on-force-push" as const,
label: "Smart",
description: "Snapshot only on history rewrites",
icon: Sparkles,
info: "Backs up only when a force-push is detected.",
},
{
value: "block-on-force-push" as const,
label: "Block & Approve",
description: "Hold sync until you approve",
icon: Hand,
info: "Force-pushed repos pause syncing until you approve the update.",
},
];
export function GitHubConfigForm({
config,
setConfig,
mirrorOptions,
@@ -49,7 +92,8 @@ export function GitHubConfigForm({
onMirrorOptionsAutoSave,
onAdvancedOptionsAutoSave,
onGiteaAutoSave,
isAutoSaving
isAutoSaving,
part = "connection"
}: GitHubConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
@@ -93,267 +137,254 @@ export function GitHubConfigForm({
}
};
return (
<Card className="w-full h-full flex flex-col">
<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="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
const updateGitea = (newConfig: GiteaConfig) => {
if (!setGiteaConfig) return;
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
};
<CardContent className="flex flex-col gap-y-6 flex-1">
<div>
<label
htmlFor="github-username"
className="block text-sm font-medium mb-1.5"
>
GitHub Username
</label>
<Input
id="github-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your GitHub username"
required
className="bg-background"
/>
</div>
const backupStrategy = giteaConfig?.backupStrategy ?? "on-force-push";
<div>
<div className="flex items-center gap-2 mb-1.5">
<label
htmlFor="github-token"
className="block text-sm font-medium"
if (part === "connection") {
return (
<SettingsCard
icon={SiGithub}
title="GitHub Connection"
headerAction={
<div className="flex items-center gap-3">
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={testConnection}
disabled={isLoading || !config.token}
>
GitHub Token
</label>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
<Info className="h-3.5 w-3.5 text-muted-foreground" />
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
{isLoading ? "Testing..." : "Test"}
</Button>
</div>
}
>
<CardSection>
<div className="space-y-1.5">
<Label
htmlFor="github-username"
className="text-xs font-medium text-muted-foreground"
>
Username
</Label>
<Input
id="github-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your GitHub username"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="github-token"
className="text-xs font-medium text-muted-foreground"
>
Personal access token
</Label>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
placeholder="Your GitHub token (classic)"
/>
<p className="text-[11px] text-muted-foreground/80">
Needed for private repos, organizations, and starred repositories
</p>
</div>
<div className="space-y-3 rounded-lg bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-semibold text-muted-foreground">
Creating your token
</span>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-80">
<div className="space-y-2">
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
<div className="text-sm space-y-2">
<p>
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
</p>
<ul className="ml-4 space-y-1 list-disc">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
</ul>
<p className="text-muted-foreground">
The organization access is required for mirroring organization repositories.
</p>
<p>
Generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
>
github.com/settings/tokens
</a>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
title="Open github.com/settings/tokens"
aria-label="Open GitHub token settings"
className="text-indigo-500 hover:text-indigo-400"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<ol className="list-decimal space-y-1.5 pl-4 text-xs leading-relaxed text-muted-foreground">
<li>GitHub → Settings → Developer settings</li>
<li>Personal access tokens → Generate new token (classic)</li>
<li>Select the scopes below and paste the token here</li>
</ol>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
repo
</code>
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
admin:org
</code>
</div>
</div>
</CardSection>
</SettingsCard>
);
}
return (
<div className="flex flex-col gap-6">
<GitHubMirrorSettings
part="selection"
githubConfig={config}
mirrorOptions={mirrorOptions}
advancedOptions={advancedOptions}
onGitHubConfigChange={(newConfig) => {
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onMirrorOptionsChange={(newOptions) => {
setMirrorOptions(newOptions);
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
}}
onAdvancedOptionsChange={(newOptions) => {
setAdvancedOptions(newOptions);
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
{/* Destructive Update Protection */}
{giteaConfig && setGiteaConfig && (
<SettingsCard
icon={ShieldAlert}
title="Destructive Update Protection"
footer={
<StatusFooterItem
icon={ShieldCheck}
label="Applies to Always Backup and Smart modes"
/>
}
>
<CardSection>
<SectionTitle>How to handle force-pushes on GitHub</SectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{backupStrategies.map((opt) => (
<OptionTile
key={opt.value}
icon={opt.icon}
label={opt.label}
description={opt.description}
info={opt.info}
selected={backupStrategy === opt.value}
onSelect={() =>
updateGitea({
...giteaConfig,
backupStrategy: opt.value as BackupStrategy,
})
}
/>
))}
</div>
</CardSection>
{backupStrategy !== "disabled" && (
<>
<CardDivider />
<CardSection>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label
htmlFor="backup-retention"
className="text-xs font-medium text-muted-foreground"
>
Snapshot retention count
</Label>
<Input
id="backup-retention"
name="backupRetentionCount"
type="number"
min={1}
value={giteaConfig.backupRetentionCount ?? 5}
onChange={(e) =>
updateGitea({
...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
})
}
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="backup-retention-days"
className="text-xs font-medium text-muted-foreground"
>
Snapshot retention days
</Label>
<Input
id="backup-retention-days"
name="backupRetentionDays"
type="number"
min={0}
value={giteaConfig.backupRetentionDays ?? 30}
onChange={(e) =>
updateGitea({
...giteaConfig,
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
})
}
/>
<p className="text-[11px] text-muted-foreground/80">
0 = no time-based limit
</p>
</div>
<div className="space-y-1.5">
<Label
htmlFor="backup-directory"
className="text-xs font-medium text-muted-foreground"
>
Snapshot directory
</Label>
<Input
id="backup-directory"
name="backupDirectory"
type="text"
value={giteaConfig.backupDirectory || "data/repo-backups"}
onChange={(e) =>
updateGitea({ ...giteaConfig, backupDirectory: e.target.value })
}
placeholder="data/repo-backups"
/>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
className="bg-background"
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
/>
<p className="text-xs text-muted-foreground mt-1">
Required for private repositories, organizations, and starred
repositories.
</p>
</div>
<Separator />
<GitHubMirrorSettings
githubConfig={config}
mirrorOptions={mirrorOptions}
advancedOptions={advancedOptions}
onGitHubConfigChange={(newConfig) => {
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onMirrorOptionsChange={(newOptions) => {
setMirrorOptions(newOptions);
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
}}
onAdvancedOptionsChange={(newOptions) => {
setAdvancedOptions(newOptions);
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
{giteaConfig && setGiteaConfig && (
<>
<Separator />
<div className="space-y-4">
<h3 className="text-sm font-medium flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-primary" />
Destructive Update Protection
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
</h3>
<p className="text-xs text-muted-foreground">
Choose how to handle force-pushes or rewritten upstream history on GitHub.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{([
{
value: "disabled",
label: "Disabled",
desc: "No detection or backups",
},
{
value: "always",
label: "Always Backup",
desc: "Snapshot before every sync",
},
{
value: "on-force-push",
label: "Smart",
desc: "Backup only on force-push",
},
{
value: "block-on-force-push",
label: "Block & Approve",
desc: "Require approval on force-push",
},
] as const).map((opt) => {
const isSelected = (giteaConfig.backupStrategy ?? "on-force-push") === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
const newConfig = { ...giteaConfig, backupStrategy: opt.value as BackupStrategy };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left text-sm transition-colors ${
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-input hover:bg-accent hover:text-accent-foreground"
}`}
>
<span className="font-medium">{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.desc}</span>
</button>
);
})}
</div>
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
Snapshot retention count
</label>
<input
id="backup-retention"
name="backupRetentionCount"
type="number"
min={1}
value={giteaConfig.backupRetentionCount ?? 20}
onChange={(e) => {
const newConfig = {
...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 20),
};
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
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"
/>
</div>
<div>
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
Snapshot directory
</label>
<input
id="backup-directory"
name="backupDirectory"
type="text"
value={giteaConfig.backupDirectory || "data/repo-backups"}
onChange={(e) => {
const newConfig = { ...giteaConfig, backupDirectory: e.target.value };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
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="data/repo-backups"
/>
</div>
</div>
{((giteaConfig.backupStrategy ?? "on-force-push") === "always" ||
(giteaConfig.backupStrategy ?? "on-force-push") === "on-force-push") && (
<label className="flex items-start gap-3 text-sm">
<input
name="blockSyncOnBackupFailure"
type="checkbox"
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
onChange={(e) => {
const newConfig = { ...giteaConfig, blockSyncOnBackupFailure: e.target.checked };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
className="mt-0.5 rounded border-input"
/>
<span>
Block sync when snapshot fails
<p className="text-xs text-muted-foreground">
Recommended for backup-first behavior. If disabled, sync continues even when snapshot creation fails.
</p>
</span>
</label>
)}
</>
)}
</div>
</>
)}
{/* 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>
{(backupStrategy === "always" || backupStrategy === "on-force-push") && (
<SwitchRow
label="Block sync when snapshot fails"
description="Recommended so a failed backup never lets a destructive sync through"
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
onCheckedChange={(checked) =>
updateGitea({ ...giteaConfig, blockSyncOnBackupFailure: checked })
}
/>
)}
</CardSection>
</>
)}
</SettingsCard>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+223 -172
View File
@@ -1,17 +1,20 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { giteaApi } from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Activity, AlertTriangle, Building2, Info, PlugZap, Server } from "lucide-react";
import { giteaApi, type GiteaServerInfo } from "@/lib/api";
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
import { toast } from "sonner";
import { OrganizationStrategy } from "./OrganizationStrategy";
import { OrganizationConfiguration } from "./OrganizationConfiguration";
import { Separator } from "../ui/separator";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import {
SettingsCard,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface GiteaConfigFormProps {
config: GiteaConfig;
@@ -19,11 +22,14 @@ interface GiteaConfigFormProps {
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
isAutoSaving?: boolean;
githubUsername?: string;
/** Which card to render: the connection card or the organization card. */
part?: "connection" | "organization";
}
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername, part = "connection" }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
// Derive the mirror strategy from existing config for backward compatibility
const getMirrorStrategy = (): MirrorStrategy => {
if (config.mirrorStrategy) return config.mirrorStrategy;
@@ -33,13 +39,15 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
if (config.organization && config.organization !== config.username) return "single-org";
return "flat-user";
};
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
// Update config when strategy changes
// Update config when strategy changes. Only the organization instance runs
// this effect so a second (connection) instance can't double-fire autosave.
useEffect(() => {
if (part !== "organization") return;
const newConfig = { ...config };
switch (mirrorStrategy) {
case "preserve":
newConfig.preserveOrgStructure = true;
@@ -72,7 +80,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
}
break;
}
setConfig(newConfig);
if (onAutoSave) {
onAutoSave(newConfig);
@@ -128,13 +136,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
try {
const result = await giteaApi.testConnection(config.url, config.token);
if (result.success) {
setServerInfo(result.serverInfo ?? null);
toast.success("Successfully connected to Gitea!");
} else {
setServerInfo(null);
toast.error(
"Failed to connect to Gitea. Please check your URL and token."
);
}
} catch (error) {
setServerInfo(null);
toast.error(
error instanceof Error ? error.message : "An unknown error occurred"
);
@@ -143,166 +154,206 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
}
};
if (part === "connection") {
return (
<SettingsCard
icon={Server}
title="Gitea Connection"
headerAction={
<div className="flex items-center gap-3">
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
>
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
{isLoading ? "Testing..." : "Test"}
</Button>
</div>
}
footer={
serverInfo ? (
<StatusFooterItem
icon={Info}
label={`${serverInfo.type === "forgejo" ? "Forgejo" : "Gitea"} ${serverInfo.version} detected`}
/>
) : undefined
}
>
<CardSection>
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
Forgejo {serverInfo.version} has a known mirror-credential bug
</AlertTitle>
<AlertDescription>
<p>
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
<a
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
PR #11909
</a>
).
</p>
<p>
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-1.5">
<Label
htmlFor="gitea-username"
className="text-xs font-medium text-muted-foreground"
>
Username
</Label>
<Input
id="gitea-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your Gitea username"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-url"
className="text-xs font-medium text-muted-foreground"
>
Server URL
</Label>
<Input
id="gitea-url"
name="url"
type="url"
value={config.url}
onChange={handleChange}
placeholder="https://your-gitea-instance.com"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-external-url"
className="text-xs font-medium text-muted-foreground"
>
External URL (optional)
</Label>
<Input
id="gitea-external-url"
name="externalUrl"
type="url"
value={config.externalUrl || ""}
onChange={handleChange}
placeholder="https://gitea.example.com"
/>
<p className="text-[11px] text-muted-foreground/80">
Dashboard links only, syncing always uses the server URL
</p>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-token"
className="text-xs font-medium text-muted-foreground"
>
Access token
</Label>
<Input
id="gitea-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
placeholder="Your Gitea access token"
required
/>
<p className="text-[11px] text-muted-foreground/80">
Create one in Gitea under Settings → Applications
</p>
</div>
</CardSection>
</SettingsCard>
);
}
return (
<Card className="w-full h-full flex flex-col">
<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="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6 flex-1">
<div>
<label
htmlFor="gitea-username"
className="block text-sm font-medium mb-1.5"
>
Gitea Username
</label>
<input
id="gitea-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
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="Your Gitea username"
required
<>
{/* Organization Structure */}
<SettingsCard
icon={Building2}
title="Organization Structure"
footer={
<StatusFooterItem
icon={Info}
label="Strategy applies to newly mirrored repositories"
/>
</div>
<div>
<label
htmlFor="gitea-url"
className="block text-sm font-medium mb-1.5"
>
Gitea URL
</label>
<input
id="gitea-url"
name="url"
type="url"
value={config.url}
onChange={handleChange}
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="https://your-gitea-instance.com"
required
}
>
<CardSection>
<OrganizationStrategy
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername}
giteaUsername={config.username}
/>
</div>
<div>
<label
htmlFor="gitea-external-url"
className="block text-sm font-medium mb-1.5"
>
Gitea External URL (optional)
</label>
<input
id="gitea-external-url"
name="externalUrl"
type="url"
value={config.externalUrl || ""}
onChange={handleChange}
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="https://gitea.example.com"
</CardSection>
<CardDivider />
<CardSection>
<OrganizationConfiguration
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposOrgChange={(org) => {
const newConfig = { ...config, starredReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposModeChange={(mode) => {
const newConfig = { ...config, starredReposMode: mode };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
/>
<p className="text-xs text-muted-foreground mt-1">
Used only for dashboard links. API sync still uses Gitea URL.
</p>
</div>
<div>
<label
htmlFor="gitea-token"
className="block text-sm font-medium mb-1.5"
>
Gitea Token
</label>
<input
id="gitea-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
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="Your Gitea access token"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Create a token in your Gitea instance under Settings &gt;
Applications.
</p>
</div>
<Separator />
<OrganizationStrategy
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername}
giteaUsername={config.username}
/>
<Separator />
<OrganizationConfiguration
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposOrgChange={(org) => {
const newConfig = { ...config, starredReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposModeChange={(mode) => {
const newConfig = { ...config, starredReposMode: mode };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
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>
</CardSection>
</SettingsCard>
</>
);
}
@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { MirrorOverrides } from "@/lib/db/schema";
import {
getMirrorOverrideGating,
MIRROR_OVERRIDE_LABELS,
UI_MIRROR_OVERRIDE_KEYS,
type MirrorOverrideKey,
} from "@/lib/utils/mirror-overrides";
/**
* Tri-state value for one flag in the dialog. "inherit" persists as absent,
* which is what lets the next tier out supply the value.
*/
type TriState = "inherit" | "on" | "off";
function toTriState(value: boolean | null | undefined): TriState {
if (value === true) return "on";
if (value === false) return "off";
return "inherit";
}
function fromTriState(value: TriState): boolean | undefined {
if (value === "on") return true;
if (value === "off") return false;
return undefined;
}
export interface MirrorOverridesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Display name of the repo or org being edited. */
targetName: string;
/** "repository" or "organization", used for wording and gating rules. */
targetKind: "repository" | "organization";
value?: MirrorOverrides | null;
/**
* Effective values from the tiers above this one, used to label what
* "Inherit" actually resolves to right now. For a repository this should
* already be global merged with its organization's overrides.
*/
inheritedFrom?: Partial<Record<MirrorOverrideKey, boolean>>;
inheritedLabel?: string;
/**
* True while the inherited values are still being fetched. The hint is
* suppressed rather than shown wrong and then corrected.
*/
inheritedLoading?: boolean;
/** Repository-only: drives the starred-code-only gating. */
isStarred?: boolean;
starredCodeOnly?: boolean;
onSave: (overrides: MirrorOverrides | null) => Promise<void>;
}
export function MirrorOverridesDialog({
open,
onOpenChange,
targetName,
targetKind,
value,
inheritedFrom,
inheritedLabel = "global settings",
inheritedLoading = false,
isStarred,
starredCodeOnly,
onSave,
}: MirrorOverridesDialogProps) {
const [draft, setDraft] = useState<Record<MirrorOverrideKey, TriState>>(
() => buildDraft(value)
);
const [isSaving, setIsSaving] = useState(false);
// Re-seed whenever the dialog opens or targets a different object, so a
// previous edit never leaks into the next one.
useEffect(() => {
if (open) setDraft(buildDraft(value));
}, [open, value]);
// What each flag currently resolves to, including the in-progress edit.
// Drives the labels gate so it reacts live as issues is toggled.
const effective = useMemo(() => {
const next: Partial<Record<MirrorOverrideKey, boolean>> = {};
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
const pinned = fromTriState(draft[key]);
next[key] = pinned ?? inheritedFrom?.[key] ?? false;
}
return next;
}, [draft, inheritedFrom]);
const gating = useMemo(
() =>
getMirrorOverrideGating({
targetKind,
isStarred,
starredCodeOnly,
effective,
}),
[targetKind, isStarred, starredCodeOnly, effective]
);
const overriddenCount = useMemo(
() => Object.values(draft).filter((state) => state !== "inherit").length,
[draft]
);
const handleSave = async () => {
setIsSaving(true);
try {
// Gated flags keep whatever the user previously stored. The clamp is a
// runtime concern, so a disabled toggle must not silently erase data.
const next: MirrorOverrides = {};
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
const resolved = fromTriState(draft[key]);
if (typeof resolved === "boolean") next[key] = resolved;
}
await onSave(Object.keys(next).length > 0 ? next : null);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
const handleResetAll = () => {
setDraft(buildDraft(null));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mirror options</DialogTitle>
<DialogDescription>
Override what gets mirrored for this {targetKind}.{" "}
<span className="font-medium text-foreground">{targetName}</span>{" "}
inherits anything left on Inherit.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{UI_MIRROR_OVERRIDE_KEYS.map((key) => {
const disabledReason = gating[key];
const isDisabled = !!disabledReason;
const inherited = inheritedFrom?.[key];
const showHint =
!inheritedLoading &&
!isDisabled &&
draft[key] === "inherit" &&
inherited !== undefined;
return (
<div key={key} className="space-y-1">
<div className="flex items-center justify-between gap-4">
<Label
htmlFor={`override-${key}`}
className={isDisabled ? "flex-1 text-muted-foreground" : "flex-1"}
>
{MIRROR_OVERRIDE_LABELS[key]}
{showHint && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
currently {inherited ? "on" : "off"}
</span>
)}
</Label>
<Select
value={draft[key]}
disabled={isDisabled}
onValueChange={(next) =>
setDraft((prev) => ({ ...prev, [key]: next as TriState }))
}
>
<SelectTrigger id={`override-${key}`} className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="on">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
{isDisabled && (
<p className="text-xs text-muted-foreground">
{disabledReason}
</p>
)}
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Inherit uses the {inheritedLabel}. Git LFS is worth turning off for
repositories whose LFS fetch fails, since Gitea aborts the whole
migration when it cannot pull them.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={handleResetAll}
disabled={isSaving || overriddenCount === 0}
>
Reset all
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function buildDraft(
value: MirrorOverrides | null | undefined
): Record<MirrorOverrideKey, TriState> {
const draft = {} as Record<MirrorOverrideKey, TriState>;
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
draft[key] = toTriState(value?.[key]);
}
return draft;
}
@@ -0,0 +1,522 @@
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Bell, Activity, Send, Info } from "lucide-react";
import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
import {
SettingsCard,
SectionTitle,
SwitchRow,
SegmentedControl,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface NotificationSettingsProps {
notificationConfig: NotificationConfig;
onNotificationChange: (config: NotificationConfig) => void;
isAutoSaving?: boolean;
}
type Provider = "ntfy" | "apprise" | "gotify" | "webhook";
const providerOptions: { value: Provider; label: string }[] = [
{ value: "ntfy", label: "Ntfy.sh" },
{ value: "apprise", label: "Apprise" },
{ value: "gotify", label: "Gotify" },
{ value: "webhook", label: "Webhook" },
];
function Field({
id,
label,
required,
helper,
children,
}: {
id: string;
label: string;
required?: boolean;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{helper && (
<p className="text-[11px] text-muted-foreground/80">{helper}</p>
)}
</div>
);
}
export function NotificationSettings({
notificationConfig,
onNotificationChange,
isAutoSaving,
}: NotificationSettingsProps) {
const [isTesting, setIsTesting] = useState(false);
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }),
});
const result = await resp.json();
if (result.success) {
toast.success("Test notification sent successfully!");
} else {
toast.error(`Test failed: ${result.error || "Unknown error"}`);
}
} catch (error) {
toast.error(
`Test failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsTesting(false);
}
};
return (
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<SettingsCard
icon={Bell}
title="Notifications"
enabled={notificationConfig.enabled}
onEnabledChange={(checked) =>
onNotificationChange({ ...notificationConfig, enabled: checked })
}
headerAction={
isAutoSaving ? (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
) : undefined
}
footer={
notificationConfig.enabled ? (
<>
<StatusFooterItem
icon={Info}
label="Sends a test message with your current settings"
/>
<Button
size="sm"
onClick={handleTestNotification}
disabled={isTesting}
className="bg-indigo-500 text-white hover:bg-indigo-600"
>
{isTesting ? (
<>
<Activity className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-3.5 w-3.5" />
Send test
</>
)}
</Button>
</>
) : (
<StatusFooterItem
icon={Info}
label="Get alerted when mirror jobs complete or fail"
/>
)
}
>
{notificationConfig.enabled && (
<CardSection>
<SectionTitle>Provider</SectionTitle>
<SegmentedControl
options={providerOptions}
value={notificationConfig.provider}
onChange={(value) =>
onNotificationChange({ ...notificationConfig, provider: value })
}
/>
{notificationConfig.provider === "ntfy" && (
<div className="space-y-4 pt-2">
<Field
id="ntfy-url"
label="Server URL"
helper="Use https://ntfy.sh for the public server or your self-hosted instance URL"
>
<Input
id="ntfy-url"
type="url"
placeholder="https://ntfy.sh"
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: e.target.value,
topic: notificationConfig.ntfy?.topic || "",
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</Field>
<Field
id="ntfy-topic"
label="Topic"
required
helper="Choose a unique topic name. Anyone with the topic name can subscribe."
>
<Input
id="ntfy-topic"
placeholder="gitea-mirror"
value={notificationConfig.ntfy?.topic || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</Field>
<Field
id="ntfy-token"
label="Access token (optional)"
helper="Required if your ntfy server uses authentication"
>
<Input
id="ntfy-token"
type="password"
placeholder="tk_..."
value={notificationConfig.ntfy?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
token: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
</Field>
<Field
id="ntfy-priority"
label="Default priority"
helper='Error notifications always use "high" priority regardless of this setting'
>
<Select
value={notificationConfig.ntfy?.priority || "default"}
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
priority: value,
},
})
}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="min">Min</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
{notificationConfig.provider === "apprise" && (
<div className="space-y-4 pt-2">
<Field
id="apprise-url"
label="Server URL"
required
helper="URL of your Apprise API server (e.g., http://apprise:8000)"
>
<Input
id="apprise-url"
type="url"
placeholder="http://apprise:8000"
value={notificationConfig.apprise?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: e.target.value,
token: notificationConfig.apprise?.token || "",
},
})
}
/>
</Field>
<Field
id="apprise-token"
label="Token / path"
required
helper="The Apprise API configuration token or key"
>
<Input
id="apprise-token"
placeholder="gitea-mirror"
value={notificationConfig.apprise?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: e.target.value,
},
})
}
/>
</Field>
<Field
id="apprise-tag"
label="Tag filter (optional)"
helper="Optional tag to filter which Apprise services receive notifications"
>
<Input
id="apprise-tag"
placeholder="all"
value={notificationConfig.apprise?.tag || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: notificationConfig.apprise?.token || "",
tag: e.target.value,
},
})
}
/>
</Field>
</div>
)}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 pt-2">
<Field
id="gotify-url"
label="Server URL"
required
helper="URL of your Gotify server"
>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</Field>
<Field
id="gotify-token"
label="Application token"
required
helper="Create an application in Gotify and paste its token here"
>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</Field>
<Field
id="gotify-priority"
label="Default priority (0-10)"
helper="Error notifications always use priority 8 regardless of this setting"
>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</Field>
</div>
)}
{notificationConfig.provider === "webhook" && (
<div className="space-y-4 pt-2">
<Field
id="webhook-url"
label="Webhook URL"
required
helper="Notifications are sent as a JSON POST with title, message, type, and timestamp fields"
>
<Input
id="webhook-url"
type="url"
placeholder="https://example.com/hooks/gitea-mirror"
value={notificationConfig.webhook?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: e.target.value,
},
})
}
/>
</Field>
<Field
id="webhook-secret"
label="Signing secret (optional)"
helper="If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...)"
>
<Input
id="webhook-secret"
type="password"
placeholder="whsec_..."
value={notificationConfig.webhook?.secret || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: notificationConfig.webhook?.url || "",
secret: e.target.value,
},
})
}
/>
</Field>
</div>
)}
</CardSection>
)}
</SettingsCard>
{notificationConfig.enabled && (
<SettingsCard
icon={Activity}
title="Notification Events"
footer={
<StatusFooterItem
icon={Info}
label="Error notifications are always sent at high priority"
/>
}
>
<CardSection>
<SwitchRow
label="Sync errors"
description="Notify when a mirror job fails"
checked={notificationConfig.notifyOnSyncError}
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnSyncError: checked,
})
}
/>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Sync success"
description="Notify when a mirror job completes successfully"
checked={notificationConfig.notifyOnSyncSuccess}
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnSyncSuccess: checked,
})
}
/>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="New repository discovered"
description="Notify when a new GitHub repository is auto-imported"
badge="SOON"
checked={notificationConfig.notifyOnNewRepo}
disabled
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnNewRepo: checked,
})
}
/>
</CardSection>
</SettingsCard>
)}
</div>
);
}
@@ -50,16 +50,13 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<MonitorCog className="h-4 w-4" />
Organization Configuration
</h4>
</div>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Organization configuration
</span>
<div className="space-y-2">
<Label className="text-sm font-normal flex items-center gap-2">
Starred Repository Destination
<Label className="text-xs font-medium text-muted-foreground flex items-center gap-2">
Starred repository destination
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
@@ -71,43 +68,43 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</Tooltip>
</TooltipProvider>
</Label>
<div className="rounded-lg border bg-muted/20 p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
onClick={() => onStarredReposModeChange("dedicated-org")}
aria-pressed={activeStarredMode === "dedicated-org"}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-all",
activeStarredMode === "dedicated-org"
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Dedicated Organization
</button>
<button
type="button"
onClick={() => onStarredReposModeChange("preserve-owner")}
aria-pressed={activeStarredMode === "preserve-owner"}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-all",
activeStarredMode === "preserve-owner"
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Preserve Source Owner/Org
</button>
</div>
<p className="mt-2 px-1 text-xs text-muted-foreground">
{
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
<button
type="button"
role="tab"
aria-selected={activeStarredMode === "dedicated-org"}
onClick={() => onStarredReposModeChange("dedicated-org")}
className={cn(
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
activeStarredMode === "dedicated-org"
? "All starred repositories go to a single destination organization."
: "Starred repositories keep their original GitHub Owner/Org destination."
}
</p>
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Dedicated organization
</button>
<button
type="button"
role="tab"
aria-selected={activeStarredMode === "preserve-owner"}
onClick={() => onStarredReposModeChange("preserve-owner")}
className={cn(
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
activeStarredMode === "preserve-owner"
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preserve source owner
</button>
</div>
<p className="text-[11px] text-muted-foreground/80">
{
activeStarredMode === "dedicated-org"
? "All starred repositories go to a single destination organization"
: "Starred repositories keep their original GitHub Owner/Org destination"
}
</p>
</div>
{/* First row - Organization inputs */}
@@ -199,7 +196,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</Tooltip>
</TooltipProvider>
</Label>
<div className="grid grid-cols-3 gap-2">
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
{visibilityOptions.map((option) => {
const Icon = option.icon;
const isSelected = visibility === option.value;
@@ -209,20 +206,18 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => onVisibilityChange(option.value)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
"border group",
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
isSelected
? "bg-accent border-accent-foreground/20"
: "bg-background hover:bg-accent/50 border-input"
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<div className="flex items-center gap-2">
<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 hidden sm:inline-block" />
<Icon className="h-3 w-3" />
{option.label}
</button>
</TooltipTrigger>
<TooltipContent>
+75 -82
View File
@@ -1,7 +1,5 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
import { CircleCheck, Info, GitBranch, FolderTree, Star, Building2, User } from "lucide-react";
import {
HoverCard,
HoverCardContent,
@@ -291,13 +289,9 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<div className="space-y-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" />
Organization Strategy
</h4>
<p className="text-xs text-muted-foreground">
Choose how your repositories will be organized in Gitea
</p>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Organization strategy
</span>
</div>
<div className="flex-shrink-0">
@@ -363,84 +357,83 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
</div>
</div>
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
const isSelected = strategy === key;
const Icon = config.icon;
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-3" role="radiogroup">
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
const isSelected = strategy === key;
const Icon = config.icon;
return (
<div key={key}>
<label htmlFor={key} className="cursor-pointer">
<Card
return (
<button
key={key}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onStrategyChange(key)}
className={cn(
"flex items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
isSelected
? "border-indigo-500 bg-indigo-500/10"
: "border-border hover:border-muted-foreground/40"
)}
>
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
isSelected
? "bg-indigo-500/20 text-indigo-400"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 min-w-0 space-y-1">
<span className="flex items-center gap-1.5">
<span
className={cn(
"relative",
isSelected && `${config.borderColor} border-2`,
!isSelected && "border-muted"
"text-[13px] font-medium leading-none",
isSelected ? "text-foreground" : "text-muted-foreground"
)}
>
<div className="p-3 sm:p-4">
<div className="flex items-start gap-3">
<RadioGroupItem
value={key}
id={key}
className="mt-1"
{config.title}
</span>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</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}
starredReposMode={starredReposMode}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
<div className={cn(
"rounded-lg p-2 flex-shrink-0",
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
)}>
<Icon className={cn(
"h-4 w-4",
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
)} />
</div>
<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>
<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}
starredReposMode={starredReposMode}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
</div>
</div>
</Card>
</label>
</div>
);
})}
</div>
</RadioGroup>
</HoverCardContent>
</HoverCard>
</span>
<span className="block text-[11px] leading-relaxed text-muted-foreground">
{config.description}
</span>
</span>
{isSelected && (
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
)}
</button>
);
})}
</div>
</div>
);
};
+96 -87
View File
@@ -8,12 +8,14 @@ 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 { Plus, Trash2, Loader2, AlertCircle, Info, KeyRound, Shield, ShieldCheck, 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';
import { withBase } from '@/lib/base-path';
import { SwitchRow, accentSwitch } from './settings-ui';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try {
@@ -100,6 +102,9 @@ export function SSOSettings() {
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
const buildAbsoluteAppUrl = (path: string) =>
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
@@ -179,8 +184,8 @@ export function SSOSettings() {
} 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.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
requestData.audience = providerForm.audience || appOrigin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm;
@@ -285,91 +290,75 @@ export function SSOSettings() {
}
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 className="grid grid-cols-1 items-stretch gap-6 lg:grid-cols-2">
{/* Authentication Methods Overview */}
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 px-6 py-4">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<h3 className="text-base font-semibold">Sign-in Methods</h3>
</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 className="border-t border-border" />
<div className="flex-1 divide-y divide-border p-6">
{/* These methods are not toggleable from the UI, so the switches
reflect real state and explain where each is controlled. */}
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Email & Password"
badge="DEFAULT"
description="Built-in accounts, always available"
info="Always enabled so you can never lock yourself out."
checked
disabled
onCheckedChange={() => {}}
/>
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Single Sign-On (OIDC)"
description="Sign in through an external identity provider"
info={
providers.length > 0
? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured`
: "Turns on automatically when you add an identity provider."
}
checked={providers.length > 0}
disabled
onCheckedChange={() => {}}
/>
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Header Authentication"
description="Trust identity headers from your reverse proxy"
info="Controlled by the HEADER_AUTH environment variables. See the environment variable docs."
checked={headerAuthEnabled}
disabled
onCheckedChange={() => {}}
/>
</div>
<div className="border-t border-border" />
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
<Shield className="h-3.5 w-3.5" />
Only enable header auth behind a trusted reverse proxy
</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>
<Card className="flex h-full flex-col">
<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 className="flex items-center gap-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base font-semibold">Identity Providers</CardTitle>
<CardDescription className="text-sm">
Users sign in with their existing accounts (Google, Azure AD, Authentik...)
</CardDescription>
</div>
</div>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button>
<Button size="sm" className="bg-indigo-500 text-white hover:bg-indigo-600">
<Plus className="h-4 w-4 mr-2" />
Add Provider
Add provider
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
@@ -381,11 +370,21 @@ export function SSOSettings() {
: 'Configure an external identity provider for user authentication'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-1 -mx-1">
<div className="flex-1 overflow-y-auto px-1 -mx-1 [&_label]:text-xs [&_label]:font-medium [&_label]:text-muted-foreground">
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
<TabsList className="sticky top-0 z-10 grid w-full grid-cols-2 gap-1 rounded-lg bg-muted p-1">
<TabsTrigger
value="oidc"
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
OIDC / OAuth2
</TabsTrigger>
<TabsTrigger
value="saml"
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
SAML 2.0
</TabsTrigger>
</TabsList>
{/* Common Fields */}
@@ -509,6 +508,7 @@ export function SSOSettings() {
id="pkce"
checked={providerForm.pkce}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
className={accentSwitch}
/>
<Label htmlFor="pkce">Enable PKCE</Label>
</div>
@@ -517,7 +517,7 @@ export function SSOSettings() {
<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>
<p>Redirect URL: {buildAbsoluteAppUrl(`/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.
@@ -563,8 +563,8 @@ export function SSOSettings() {
<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>
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
</div>
</AlertDescription>
</Alert>
@@ -605,7 +605,11 @@ export function SSOSettings() {
>
Cancel
</Button>
<Button onClick={createProvider} disabled={addingProvider}>
<Button
onClick={createProvider}
disabled={addingProvider}
className="bg-indigo-500 text-white hover:bg-indigo-600"
>
{addingProvider ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -620,7 +624,7 @@ export function SSOSettings() {
</Dialog>
</div>
</CardHeader>
<CardContent>
<CardContent className="flex-1">
{providers.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
@@ -721,7 +725,12 @@ export function SSOSettings() {
</div>
)}
</CardContent>
<div className="mt-auto border-t border-border" />
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
<Info className="h-3.5 w-3.5" />
Users are matched to existing accounts by email address
</div>
</Card>
</div>
);
}
}
@@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { ScheduleConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
Select,
SelectContent,
@@ -24,6 +25,9 @@ export function ScheduleConfigForm({
onAutoSave,
isAutoSaving = false,
}: ScheduleConfigFormProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
+341
View File
@@ -0,0 +1,341 @@
import type { ComponentType, ReactNode } from "react";
type IconComponent = ComponentType<{ className?: string }>;
import { CircleCheck, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
/** Indigo accent used across the redesigned settings surfaces. */
export const accentSwitch =
"data-[state=checked]:bg-indigo-500 dark:data-[state=checked]:bg-indigo-500";
interface SettingsCardProps {
icon: IconComponent;
title: string;
/** Renders a switch in the header; the card is toggled as a whole. */
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
/** Custom element on the right side of the header (e.g. a Test button). */
headerAction?: ReactNode;
footer?: ReactNode;
className?: string;
children: ReactNode;
}
export function SettingsCard({
icon: Icon,
title,
enabled,
onEnabledChange,
headerAction,
footer,
className,
children,
}: SettingsCardProps) {
return (
<div
className={cn(
"flex flex-col rounded-xl border border-border bg-card overflow-hidden",
className
)}
>
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<div className="flex items-center gap-3">
{headerAction}
{onEnabledChange && (
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
className={accentSwitch}
aria-label={`Enable ${title.toLowerCase()}`}
/>
)}
</div>
</div>
<div className="border-t border-border" />
<div className="flex-1 flex flex-col">{children}</div>
{footer && (
<>
<div className="border-t border-border" />
<div className="flex items-center justify-between gap-4 px-6 py-3.5">
{footer}
</div>
</>
)}
</div>
);
}
export function SectionTitle({
children,
badge,
action,
}: {
children: ReactNode;
badge?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{children}
</span>
{badge && (
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
{badge}
</span>
)}
</div>
{action}
</div>
);
}
export function InfoHint({ content }: { content: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground">
<Info className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{content}</TooltipContent>
</Tooltip>
);
}
interface OptionRowProps {
icon?: IconComponent;
label: string;
description?: string;
info?: ReactNode;
badge?: string;
/** Control rendered on the right (switch, pill, input...). */
right?: ReactNode;
className?: string;
}
export function OptionRow({
icon: Icon,
label,
description,
info,
badge,
right,
className,
}: OptionRowProps) {
return (
<div className={cn("flex items-center gap-4", className)}>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
<span className="text-sm font-medium leading-none">{label}</span>
{badge && (
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
{badge}
</span>
)}
{info && <InfoHint content={info} />}
</div>
{description && (
<p className="text-[13px] text-muted-foreground">{description}</p>
)}
</div>
{right}
</div>
);
}
/** Switch row shorthand used by most option lists. */
export function SwitchRow(
props: Omit<OptionRowProps, "right"> & {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
/** Extra element rendered between text and switch (e.g. "Latest 10"). */
extra?: ReactNode;
}
) {
const { checked, onCheckedChange, disabled, extra, ...rest } = props;
return (
<OptionRow
{...rest}
right={
<div className="flex items-center gap-3">
{extra}
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={accentSwitch}
aria-label={rest.label}
/>
</div>
}
/>
);
}
interface OptionTileProps {
icon: IconComponent;
label: string;
description: string;
selected: boolean;
onSelect: () => void;
info?: ReactNode;
disabled?: boolean;
}
/** Selection tile ("variant C"): icon chip, label with info, description,
* and a check mark only on the selected tile. */
export function OptionTile({
icon: Icon,
label,
description,
selected,
onSelect,
info,
disabled,
}: OptionTileProps) {
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
className={cn(
"flex flex-1 items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
selected
? "border-indigo-500 bg-indigo-500/10"
: "border-border hover:border-muted-foreground/40",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
selected
? "bg-indigo-500/20 text-indigo-400"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 min-w-0 space-y-1">
<span className="flex items-center gap-1.5">
<span
className={cn(
"text-[13px] font-medium leading-none",
selected ? "text-foreground" : "text-muted-foreground"
)}
>
{label}
</span>
{info && <InfoHint content={info} />}
</span>
<span className="block text-[11px] leading-relaxed text-muted-foreground">
{description}
</span>
</span>
{selected && (
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
)}
</button>
);
}
interface SegmentedControlProps<T extends string> {
options: { value: T; label: string; icon?: IconComponent }[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
"flex w-full gap-1 rounded-lg bg-muted p-1",
className
)}
role="tablist"
>
{options.map((opt) => {
const active = opt.value === value;
const OptIcon = opt.icon;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(opt.value)}
className={cn(
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
active
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{OptIcon && <OptIcon className="h-3 w-3" />}
{opt.label}
</button>
);
})}
</div>
);
}
export function StatusFooterItem({
icon: Icon,
label,
value,
valueClassName,
}: {
icon: IconComponent;
label: string;
value?: ReactNode;
valueClassName?: string;
}) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
{value !== undefined && (
<span className={cn("font-medium text-muted-foreground", valueClassName)}>
{value}
</span>
)}
</div>
);
}
/** Divider between sections inside a SettingsCard. */
export function CardDivider() {
return <div className="border-t border-border" />;
}
/** Standard padded section body inside a SettingsCard. */
export function CardSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-4 p-6", className)}>{children}</div>;
}
+9 -10
View File
@@ -16,6 +16,9 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
import { formatShortDateTime } from "@/lib/utils/time-format";
import { useTimeFormat } from "@/hooks/useTimeFormat";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -44,17 +47,11 @@ function formatLastSyncTime(date: Date | null): string {
}
// Helper function to format full timestamp
// Locale-aware and respects the user's 12h/24h time format preference.
function formatFullTimestamp(date: Date | null): string {
if (!date) return "";
return new Date(date).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
}).replace(',', '');
return formatShortDateTime(date).replace(',', '');
}
export function Dashboard() {
@@ -63,6 +60,8 @@ export function Dashboard() {
const isPageVisible = usePageVisibility();
const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -110,7 +109,7 @@ export function Dashboard() {
useEffectForToasts(() => {
if (!user?.id) return;
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
eventSource.addEventListener("rate-limit", (event) => {
try {
+53 -22
View File
@@ -1,20 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
import {
Activity,
CircleCheck,
Clock,
RefreshCw,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { withBase } from "@/lib/base-path";
import { useTimeFormat } from "@/hooks/useTimeFormat";
interface RecentActivityProps {
activities: MirrorJob[];
}
function activityIcon(status: MirrorJob["status"]) {
switch (status) {
case "mirrored":
case "synced":
return { Icon: CircleCheck, color: "text-green-500" };
case "mirroring":
case "syncing":
return { Icon: RefreshCw, color: "text-indigo-400" };
case "imported":
return { Icon: Sparkles, color: "text-muted-foreground" };
case "failed":
return { Icon: TriangleAlert, color: "text-red-500" };
default:
return { Icon: Activity, color: "text-muted-foreground" };
}
}
export function RecentActivity({ activities }: RecentActivityProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild>
<a href="/activity">View All</a>
<CardTitle className="flex items-center gap-3 text-base font-semibold">
<Activity className="h-5 w-5 text-muted-foreground" />
Recent Activity
</CardTitle>
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
<a href={withBase("/activity")}>View all</a>
</Button>
</CardHeader>
<CardContent>
@@ -27,7 +59,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<a href="/activity">
<a href={withBase("/activity")}>
<Activity className="h-3.5 w-3.5 mr-1.5" />
View History
</a>
@@ -36,25 +68,24 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</div>
) : (
<div className="flex flex-col divide-y divide-border">
{activities.map((activity, index) => (
<div key={index} className="flex items-center gap-x-3 py-3.5">
<div className="relative flex-shrink-0">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status
)}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{activity.message}
{activities.map((activity, index) => {
const { Icon, color } = activityIcon(activity.status);
return (
<div key={index} className="flex items-center gap-x-3 py-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className={`h-4 w-4 ${color}`} />
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(activity.timestamp)}
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">
{activity.message}
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(activity.timestamp)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</CardContent>
+8 -4
View File
@@ -6,6 +6,7 @@ import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
interface RepositoryListProps {
repositories: Repository[];
@@ -40,9 +41,12 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild>
<a href="/repositories">View All</a>
<CardTitle className="flex items-center gap-3 text-base font-semibold">
<GitFork className="h-5 w-5 text-muted-foreground" />
Repositories
</CardTitle>
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
<a href={withBase("/repositories")}>View all</a>
</Button>
</CardHeader>
<CardContent>
@@ -54,7 +58,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
Configure your GitHub connection to start mirroring repositories.
</p>
<Button asChild>
<a href="/config">Configure GitHub</a>
<a href={withBase("/config")}>Configure GitHub</a>
</Button>
</div>
) : (
+17 -13
View File
@@ -1,4 +1,3 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface StatusCardProps {
@@ -17,17 +16,22 @@ export function StatusCard({
className,
}: StatusCardProps) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="h-4 w-4 text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</CardContent>
</Card>
<div
className={cn(
"flex flex-col gap-2.5 rounded-xl border border-border bg-card p-5",
className
)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{title}
</span>
<span className="text-muted-foreground/60">{icon}</span>
</div>
<div className="text-2xl font-semibold leading-none">{value}</div>
{description && (
<p className="text-xs text-muted-foreground/80">{description}</p>
)}
</div>
);
}
+9 -47
View File
@@ -1,19 +1,13 @@
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { toast } from "sonner";
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PanelRightOpen, PanelRightClose } from "lucide-react";
import { AccountMenu } from "@/components/auth/AccountMenu";
import { withBase } from "@/lib/base-path";
interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
@@ -25,7 +19,7 @@ interface HeaderProps {
}
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
const { user, logout, isLoading } = useAuth();
const { isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -46,13 +40,6 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
};
const handleLogout = async () => {
toast.success("Logged out successfully");
// Small delay to show the toast before redirecting
await new Promise((resolve) => setTimeout(resolve, 500));
logout();
};
// Auth buttons skeleton loader
function AuthButtonsSkeleton() {
return (
@@ -101,14 +88,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
<button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
window.history.pushState({}, '', withBase('/'));
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-5 w-6"
/>
@@ -138,34 +125,9 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</Button>
)}
<ModeToggle />
<TimeFormatToggle />
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<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="sm" asChild>
<a href="/login">Login</a>
</Button>
)}
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
</div>
</div>
</header>
+3 -2
View File
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { stripBasePath, withBase } from "@/lib/base-path";
// Navigation context to signal when navigation happens
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname;
const path = stripBasePath(window.location.pathname);
const pageMap: Record<string, AppProps['page']> = {
'/': 'dashboard',
'/repositories': 'repositories',
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
if (!authLoading && !user) {
// Use window.location for client-side redirect
if (typeof window !== 'undefined') {
window.location.href = '/login';
window.location.href = withBase('/login');
}
return null;
}
+17 -5
View File
@@ -3,12 +3,14 @@ import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar";
import { VersionInfo } from "./VersionInfo";
import { ThemeSwitcher } from "@/components/theme/ThemeSwitcher";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { stripBasePath, withBase } from "@/lib/base-path";
interface SidebarProps {
className?: string;
@@ -24,14 +26,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
const path = stripBasePath(window.location.pathname);
setCurrentPath(path);
}, []);
// Listen for URL changes (browser back/forward)
useEffect(() => {
const handlePopState = () => {
setCurrentPath(window.location.pathname);
setCurrentPath(stripBasePath(window.location.pathname));
};
window.addEventListener('popstate', handlePopState);
@@ -45,7 +47,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
if (currentPath === href) return;
// Update URL without page reload
window.history.pushState({}, '', href);
window.history.pushState({}, '', withBase(href));
setCurrentPath(href);
// Map href to page name for the parent component
@@ -163,7 +165,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
href="https://gitea-mirror.raylabs.io/docs/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
@@ -177,7 +179,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
href="/docs"
href="https://gitea-mirror.raylabs.io/docs/"
target="_blank"
rel="noopener noreferrer"
className={cn(
@@ -195,6 +197,16 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
</TooltipProvider>
</div>
<div className={cn(
"mt-3 flex items-center justify-between",
isCollapsed ? "md:hidden xl:flex" : "flex"
)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
Theme
</span>
<ThemeSwitcher />
</div>
<div className={cn(
"mt-1",
isCollapsed ? "md:hidden xl:block" : "block"
)}>
<VersionInfo />
+128
View File
@@ -0,0 +1,128 @@
import * as React from "react";
import { Clock, CircleCheck, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import type { TimeFormatPreference } from "@/lib/utils/time-format";
function formatNow(now: Date, preference: TimeFormatPreference): string {
const options: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
};
if (preference === "12h") options.hour12 = true;
if (preference === "24h") options.hour12 = false;
return new Intl.DateTimeFormat(undefined, options).format(now);
}
export function TimeFormatToggle() {
const { preference, setPreference } = useTimeFormat();
const [now, setNow] = React.useState(() => new Date());
const locale =
typeof navigator !== "undefined" ? navigator.language : undefined;
// Keep the trigger clock fresh.
React.useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(timer);
}, []);
const options: {
value: TimeFormatPreference;
label: string;
example: string;
mono: boolean;
}[] = [
{
value: "auto",
label: "Auto",
example: formatNow(now, "auto"),
mono: true,
},
{ value: "12h", label: "12-hour", example: formatNow(now, "12h"), mono: true },
{ value: "24h", label: "24-hour", example: formatNow(now, "24h"), mono: true },
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="lg"
className="gap-2 px-3"
title="Time format"
>
<Clock className="h-[1.1rem] w-[1.1rem]" />
<span className="font-mono text-xs text-muted-foreground">
{formatNow(now, preference)}
</span>
<span className="sr-only">Toggle time format</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-70 p-1.5">
<DropdownMenuLabel className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Time format
</span>
{locale && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] font-normal text-muted-foreground">
<Globe className="h-3 w-3" />
{locale}
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{options.map((option) => {
const selected = preference === option.value;
return (
<DropdownMenuItem
key={option.value}
onClick={() => setPreference(option.value)}
className={cn(
"gap-2.5 rounded-lg px-2.5 py-2.5",
selected && "bg-muted/60"
)}
>
<div className="flex flex-1 items-center gap-2">
{option.value === "auto" && (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span
className={cn(
"text-[13px] leading-none",
selected && "font-semibold"
)}
>
{option.label}
</span>
</div>
<span
className={cn(
"text-xs text-muted-foreground",
option.mono && "font-mono"
)}
>
{option.example}
</span>
<CircleCheck
className={cn(
"h-4 w-4 text-indigo-500",
selected ? "opacity-100" : "opacity-0"
)}
/>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+15 -27
View File
@@ -90,41 +90,29 @@ export default function ConsentPage() {
const handleConsent = async (accept: boolean) => {
setIsSubmitting(true);
try {
// The OAuth provider redirected here with the authorization request in
// the query string (client_id, scope, code). Hand that back via
// `oauth_query` so the provider can resume the flow. It validates the
// redirect URI server-side and returns the URL to navigate to — either
// with an authorization code (accept) or an error (deny).
const oauthQuery = window.location.search.replace(/^\?/, '');
const result = await authClient.oauth2.consent({
accept,
oauth_query: oauthQuery,
scope: Array.from(selectedScopes).join(' '),
});
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');
}
}
const redirectUri = (result.data as { redirect_uri?: string } | undefined)?.redirect_uri;
if (redirectUri) {
// The redirect URI is produced and validated server-side.
window.location.href = redirectUri;
} else if (!accept) {
// No redirect target returned on denial — return to the app.
window.location.href = '/';
}
} catch (error) {
showErrorToast(error, toast);
+54 -1
View File
@@ -475,7 +475,7 @@ export function Organization() {
// Check if any filters are active
const hasActiveFilters = !!(filter.membershipRole || filter.status);
const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
const activeFilterCount = [filter.membershipRole, filter.status, filter.hasOverrides].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
@@ -483,6 +483,7 @@ export function Organization() {
searchTerm: filter.searchTerm,
membershipRole: "",
status: "",
hasOverrides: "",
});
};
@@ -653,6 +654,37 @@ export function Organization() {
</SelectContent>
</Select>
</div>
{/* Mirror Options Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Mirror options
{filter.hasOverrides && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.hasOverrides === "overridden" ? "Custom" : "Default"}
</span>
)}
</label>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All organizations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All organizations</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
@@ -793,6 +825,27 @@ export function Organization() {
))}
</SelectContent>
</Select>
{/* Mirror Options Filter */}
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-[170px] h-10">
<SelectValue placeholder="All mirror options" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All mirror options</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
{/* Action buttons */}
@@ -1,17 +1,20 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2 } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
import { hasMirrorOverrides, mirrorOptionsToFlags } from "@/lib/utils/mirror-overrides";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
import {
DropdownMenu,
DropdownMenuContent,
@@ -64,7 +67,28 @@ export function OrganizationList({
onRefresh,
onDelete,
}: OrganizationListProps) {
const { giteaConfig } = useGiteaConfig();
const { giteaConfig, mirrorOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
const handleUpdateMirrorOverrides = async (
orgId: string,
overrides: MirrorOverrides | null
) => {
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mirrorOverrides: overrides }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update mirror options");
}
if (onRefresh) {
await onRefresh();
}
};
// Helper function to construct Gitea organization URL
const getGiteaOrgUrl = (organization: Organization): string | null => {
@@ -85,7 +109,7 @@ export function OrganizationList({
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
// Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, {
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -121,6 +145,13 @@ export function OrganizationList({
result = result.filter((org) => org.status === filter.status);
}
if (filter.hasOverrides) {
const wantOverridden = filter.hasOverrides === "overridden";
result = result.filter(
(org) => hasMirrorOverrides(org.mirrorOverrides) === wantOverridden
);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["name", "type"],
@@ -189,7 +220,7 @@ export function OrganizationList({
<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 || '')}`}
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate"
>
{org.name}
@@ -264,7 +295,7 @@ export function OrganizationList({
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer"
>
{org.name}
@@ -280,13 +311,24 @@ export function OrganizationList({
</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 className="flex items-center gap-2">
{hasMirrorOverrides(org.mirrorOverrides) && (
<Badge
variant="outline"
className="text-amber-600 dark:text-amber-400 border-amber-500/40"
title="This organization overrides the global mirror options"
>
Custom options
</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>
</div>
{/* Destination override section */}
@@ -428,6 +470,10 @@ export function OrganizationList({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
{org.status !== "ignored" && (
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
@@ -589,8 +635,12 @@ export function OrganizationList({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
{org.status !== "ignored" && (
<DropdownMenuItem
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
>
<Ban className="h-4 w-4 mr-2" />
@@ -599,7 +649,7 @@ export function OrganizationList({
)}
{onDelete && (
<>
{org.status !== "ignored" && <DropdownMenuSeparator />}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => org.id && onDelete(org.id)}
@@ -684,6 +734,23 @@ export function OrganizationList({
</Card>
);
})}
<MirrorOverridesDialog
open={!!overridesTarget}
onOpenChange={(open) => {
if (!open) setOverridesTarget(null);
}}
targetKind="organization"
targetName={overridesTarget?.name ?? ""}
value={overridesTarget?.mirrorOverrides ?? null}
inheritedFrom={mirrorOptionsToFlags(mirrorOptions)}
inheritedLabel="global settings"
onSave={async (overrides) => {
if (overridesTarget?.id) {
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
}
}}
/>
</div>
);
}
+246 -108
View File
@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
const REPOSITORY_SORT_OPTIONS = [
{ value: "imported-desc", label: "Recently Imported" },
@@ -97,6 +98,8 @@ export default function Repository() {
const [repoToDelete, setRepoToDelete] = useState<Repository | null>(null);
const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false);
const [isDeletingRepo, setIsDeletingRepo] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isDeletingBulk, setIsDeletingBulk] = useState(false);
// Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -918,11 +921,9 @@ export default function Repository() {
setIsDeletingRepo(true);
try {
const response = await apiRequest<{ success: boolean; error?: string }>(
`/repositories/${repoToDelete.id}`,
{
method: "DELETE",
}
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
"/repositories",
{ method: "DELETE", body: JSON.stringify({ ids: [repoToDelete.id] }) }
);
if (response.success) {
@@ -940,6 +941,30 @@ export default function Repository() {
}
};
const handleBulkDelete = async () => {
if (!user || !user.id) return;
setIsDeletingBulk(true);
try {
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
"/repositories",
{ method: "DELETE", body: JSON.stringify({ ids: [...selectedRepoIds] }) }
);
if (response.success) {
const count = response.deleted ?? selectedRepoIds.size;
toast.success(`Removed ${count} ${count === 1 ? "repository" : "repositories"} from Gitea Mirror.`);
setSelectedRepoIds(new Set());
await fetchRepositories(false);
} else {
showErrorToast(response.error || "Failed to delete repositories", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDeletingBulk(false);
setIsBulkDeleteDialogOpen(false);
}
};
// Determine what actions are available for selected repositories
const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return [];
@@ -976,7 +1001,9 @@ export default function Repository() {
if (selectedRepos.some(repo => repo.status === "ignored")) {
actions.push('include');
}
actions.push('delete');
return actions;
};
@@ -993,6 +1020,7 @@ export default function Repository() {
retry: selectedRepos.filter(repo => repo.status === "failed").length,
ignore: selectedRepos.filter(repo => repo.status !== "ignored").length,
include: selectedRepos.filter(repo => repo.status === "ignored").length,
delete: selectedRepos.length,
};
};
@@ -1000,7 +1028,7 @@ export default function Repository() {
// Check if any filters are active
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
const activeFilterCount = [filter.owner, filter.organization, filter.status, filter.hasOverrides].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
@@ -1009,6 +1037,7 @@ export default function Repository() {
status: "",
organization: "",
owner: "",
hasOverrides: "",
sort: filter.sort || "imported-desc",
});
};
@@ -1151,6 +1180,39 @@ export default function Repository() {
</Select>
</div>
{/* Mirror Options Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Mirror options
{filter.hasOverrides && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.hasOverrides === "overridden" ? "Custom" : "Default"}
</span>
)}
</label>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all"
? ""
: (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All repositories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
@@ -1279,6 +1341,26 @@ export default function Repository() {
</SelectContent>
</Select>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-[170px] h-10">
<SelectValue placeholder="All mirror options" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All mirror options</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
<Select
value={filter.sort || "imported-desc"}
onValueChange={(value) =>
@@ -1311,111 +1393,122 @@ export default function Repository() {
</Button>
</div>
{/* Bulk actions on desktop - integrated into the same line */}
{/* Mirror All action */}
<div className="flex items-center gap-2 border-l pl-4">
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({actionCounts.mirror})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({actionCounts.sync})
</Button>
)}
{availableActions.includes('rerun-metadata') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRerunMetadata}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Re-run Metadata ({actionCounts.rerunMetadata})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="default"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="default"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
</>
)}
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
</div>
</div>
{/* Desktop: Bulk actions row - shown when repos are selected */}
{selectedRepoIds.size > 0 && (
<div className="hidden sm:flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({actionCounts.mirror})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({actionCounts.sync})
</Button>
)}
{availableActions.includes('rerun-metadata') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRerunMetadata}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Re-run Metadata ({actionCounts.rerunMetadata})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="default"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="default"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
<Button
variant="destructive"
size="default"
onClick={() => setIsBulkDeleteDialogOpen(true)}
disabled={loadingRepoIds.size > 0}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete ({actionCounts.delete})
</Button>
</div>
)}
{/* Action buttons for mobile - only show when items are selected */}
{selectedRepoIds.size > 0 && (
<div className="flex items-center gap-2 flex-wrap sm:hidden">
@@ -1505,6 +1598,16 @@ export default function Repository() {
Include
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
disabled={loadingRepoIds.size > 0}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete ({actionCounts.delete})
</Button>
</div>
</div>
)}
@@ -1518,7 +1621,7 @@ export default function Repository() {
<Button
variant="default"
onClick={() => {
window.history.pushState({}, '', '/config');
window.history.pushState({}, '', withBase('/config'));
// We need to trigger a page change event for the navigation system
window.dispatchEvent(new PopStateEvent('popstate'));
}}
@@ -1585,6 +1688,41 @@ export default function Repository() {
</DialogContent>
</Dialog>
<Dialog
open={isBulkDeleteDialogOpen}
onOpenChange={(open) => {
if (!open && !isDeletingBulk) setIsBulkDeleteDialogOpen(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"} from Gitea Mirror?</DialogTitle>
<DialogDescription>
These repositories will be deleted from Gitea Mirror only. Any mirrors on Gitea will remain untouched; remove them manually in Gitea if needed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsBulkDeleteDialogOpen(false)}
disabled={isDeletingBulk}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleBulkDelete} disabled={isDeletingBulk}>
{isDeletingBulk ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
Delete {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"}
</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteRepoDialogOpen}
onOpenChange={(open) => {
+231 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
getCoreRowModel,
getFilteredRowModel,
@@ -9,9 +9,9 @@ import {
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
import { FlipHorizontal, GitFork, MoreVertical, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, SlidersHorizontal, Trash2, X } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import type { MirrorOverrides, Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
import { formatLastSyncTime } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
@@ -26,8 +26,16 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { MarqueeText, MarqueeTrigger } from "@/components/ui/marquee-text";
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
import {
hasMirrorOverrides,
mirrorOptionsToFlags,
type MirrorOverrideKey,
} from "@/lib/utils/mirror-overrides";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
import {
DropdownMenu,
DropdownMenuContent,
@@ -98,11 +106,88 @@ export default function RepositoryTable({
onDismissSync,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig();
const lastSelectedIndexRef = useRef<number | null>(null);
const { giteaConfig, advancedOptions, mirrorOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Repository | null>(null);
// Organization-tier overrides for the repo being edited, so the dialog's
// "Inherit" hint reflects global -> org rather than global alone.
const [orgOverrides, setOrgOverrides] = useState<MirrorOverrides | null>(null);
const [orgOverridesLoading, setOrgOverridesLoading] = useState(false);
useEffect(() => {
const orgName = overridesTarget?.organization;
// Personal repos have no org tier to fetch.
if (!overridesTarget || !orgName) {
setOrgOverrides(null);
setOrgOverridesLoading(false);
return;
}
let cancelled = false;
setOrgOverridesLoading(true);
setOrgOverrides(null);
(async () => {
try {
const response = await fetch(
`${withBase("/api/organizations/mirror-overrides")}?name=${encodeURIComponent(orgName)}`
);
if (!response.ok) throw new Error("Failed to load organization overrides");
const data = await response.json();
if (!cancelled) setOrgOverrides(data.mirrorOverrides ?? null);
} catch {
// Degrade to the global values rather than blocking the dialog.
if (!cancelled) setOrgOverrides(null);
} finally {
if (!cancelled) setOrgOverridesLoading(false);
}
})();
// Ignore a late response for a repo the user already navigated away from.
return () => {
cancelled = true;
};
}, [overridesTarget]);
// Global defaults with the organization tier layered on top.
// The globals come from mirrorOptions, not giteaConfig: /api/config reshapes
// the mirror flags on the way out and does not put them on giteaConfig.
const inheritedMirrorOptions = useMemo(() => {
const globals = mirrorOptionsToFlags(mirrorOptions);
if (!orgOverrides) return globals;
for (const key of Object.keys(globals) as MirrorOverrideKey[]) {
const orgValue = orgOverrides[key];
if (typeof orgValue === "boolean") globals[key] = orgValue;
}
return globals;
}, [mirrorOptions, orgOverrides]);
const handleUpdateMirrorOverrides = async (
repoId: string,
overrides: MirrorOverrides | null
) => {
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mirrorOverrides: overrides }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update mirror options");
}
if (onRefresh) {
await onRefresh();
}
};
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination
const response = await fetch(`/api/repositories/${repoId}`, {
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -149,6 +234,7 @@ export default function RepositoryTable({
filter.status,
filter.owner,
filter.organization,
filter.hasOverrides,
].some((val) => val?.toString().trim() !== "");
const columnFilters = useMemo<ColumnFiltersState>(() => {
@@ -166,8 +252,12 @@ export default function RepositoryTable({
next.push({ id: "organization", value: filter.organization });
}
if (filter.hasOverrides) {
next.push({ id: "hasOverrides", value: filter.hasOverrides });
}
return next;
}, [filter.status, filter.owner, filter.organization]);
}, [filter.status, filter.owner, filter.organization, filter.hasOverrides]);
const sorting = useMemo(() => getTableSorting(filter.sort), [filter.sort]);
@@ -192,6 +282,13 @@ export default function RepositoryTable({
accessorFn: (row) => row.status,
filterFn: "equalsString",
},
{
id: "hasOverrides",
accessorFn: (row) =>
hasMirrorOverrides(row.mirrorOverrides) ? "overridden" : "default",
filterFn: "equalsString",
enableGlobalFilter: false,
},
{
id: "importedAt",
accessorFn: (row) => getTimestamp(row.importedAt),
@@ -234,6 +331,7 @@ export default function RepositoryTable({
// Selection handlers
const handleSelectAll = (checked: boolean) => {
lastSelectedIndexRef.current = null;
if (checked) {
const allIds = new Set(
visibleRepositories
@@ -246,7 +344,7 @@ export default function RepositoryTable({
}
};
const handleSelectRepo = (repoId: string, checked: boolean) => {
const handleSelectRepo = (repoId: string, checked: boolean, index?: number) => {
const newSelection = new Set(selectedRepoIds);
if (checked) {
newSelection.add(repoId);
@@ -254,6 +352,32 @@ export default function RepositoryTable({
newSelection.delete(repoId);
}
onSelectionChange(newSelection);
if (index !== undefined) {
lastSelectedIndexRef.current = index;
}
};
const handleShiftRangeSelect = (currentIndex: number) => {
const lastIndex = lastSelectedIndexRef.current;
if (lastIndex === null) {
const repo = visibleRepositories[currentIndex];
if (repo?.id) {
const newSelection = new Set(selectedRepoIds);
newSelection.add(repo.id);
onSelectionChange(newSelection);
lastSelectedIndexRef.current = currentIndex;
}
return;
}
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const newSelection = new Set(selectedRepoIds);
for (let i = start; i <= end; i++) {
const id = visibleRepositories[i]?.id;
if (id) newSelection.add(id);
}
onSelectionChange(newSelection);
lastSelectedIndexRef.current = currentIndex;
};
const isAllSelected =
@@ -262,7 +386,7 @@ export default function RepositoryTable({
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
// Mobile card layout for repository
const RepositoryCard = ({ repo }: { repo: Repository }) => {
const RepositoryCard = ({ repo, index }: { repo: Repository; index: number }) => {
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
const giteaUrl = getGiteaRepoUrl(repo);
@@ -273,20 +397,43 @@ export default function RepositoryTable({
<div className="flex flex-col gap-3">
{/* Header with checkbox and repo name */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/>
<div
onClickCapture={(e) => {
if (e.shiftKey && repo.id) {
e.stopPropagation();
handleShiftRangeSelect(index);
}
}}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean, index)}
className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-base truncate">{repo.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
{hasMirrorOverrides(repo.mirrorOverrides) && <Badge variant="outline" className="text-xs h-5"><SlidersHorizontal className="h-3 w-3 mr-1" />Custom</Badge>}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" disabled={isLoading}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(repo)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Repository details */}
@@ -634,8 +781,8 @@ export default function RepositoryTable({
</div>
{/* Repository cards */}
{visibleRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} />
{visibleRepositories.map((repo, index) => (
<RepositoryCard key={repo.id} repo={repo} index={index} />
))}
</div>
@@ -700,38 +847,61 @@ export default function RepositoryTable({
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<div
className="h-full p-3 flex items-center justify-center flex-[0.3]"
onClickCapture={(e) => {
if (e.shiftKey && repo.id) {
e.stopPropagation();
handleShiftRangeSelect(virtualRow.index);
}
}}
>
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked, virtualRow.index)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
<MarqueeTrigger className="h-full py-3 flex items-center gap-2 flex-[2.3] min-w-0">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-1 min-w-0">
<MarqueeText>{repo.name}</MarqueeText>
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
<Star className="h-3 w-3 shrink-0 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
<MarqueeText className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</MarqueeText>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
{hasMirrorOverrides(repo.mirrorOverrides) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 shrink-0 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-400">
Custom
</span>
</TooltipTrigger>
<TooltipContent>
This repository overrides the global mirror
options
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</MarqueeTrigger>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
@@ -801,6 +971,7 @@ export default function RepositoryTable({
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
onApproveSync={onApproveSync ? () => onApproveSync({ repoId: repo.id ?? "" }) : undefined}
onDismissSync={onDismissSync ? () => onDismissSync({ repoId: repo.id ?? "" }) : undefined}
onEditMirrorOptions={repo.id ? () => setOverridesTarget(repo) : undefined}
/>
</div>
{/* Links */}
@@ -900,6 +1071,30 @@ export default function RepositoryTable({
</div>
</>
)}
<MirrorOverridesDialog
open={!!overridesTarget}
onOpenChange={(open) => {
if (!open) setOverridesTarget(null);
}}
targetKind="repository"
targetName={overridesTarget?.fullName ?? ""}
value={overridesTarget?.mirrorOverrides ?? null}
inheritedFrom={inheritedMirrorOptions}
inheritedLoading={orgOverridesLoading}
isStarred={!!overridesTarget?.isStarred}
starredCodeOnly={!!advancedOptions?.starredCodeOnly}
inheritedLabel={
overridesTarget?.organization
? "organization or global settings"
: "global settings"
}
onSave={async (overrides) => {
if (overridesTarget?.id) {
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
}
}}
/>
</div>
);
}
@@ -914,6 +1109,7 @@ function RepoActionButton({
onDelete,
onApproveSync,
onDismissSync,
onEditMirrorOptions,
}: {
repo: { id: string; status: string };
isLoading: boolean;
@@ -924,6 +1120,7 @@ function RepoActionButton({
onDelete?: () => void;
onApproveSync?: () => void;
onDismissSync?: () => void;
onEditMirrorOptions?: () => void;
}) {
// For pending-approval repos, show approve/dismiss actions
if (repo.status === "pending-approval") {
@@ -1040,6 +1237,12 @@ function RepoActionButton({
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end">
{onEditMirrorOptions && (
<DropdownMenuItem onClick={onEditMirrorOptions}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onSkip(true)}>
<Ban className="h-4 w-4 mr-2" />
Ignore Repository
-52
View File
@@ -1,52 +0,0 @@
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const [theme, setThemeState] = React.useState<"light" | "dark" | "system">(
"light"
);
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark");
setThemeState(isDarkMode ? "dark" : "light");
}, []);
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
}, [theme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="has-[>svg]:px-3">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setThemeState("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+24 -11
View File
@@ -1,21 +1,34 @@
---
import { BASE_PATH_WINDOW_KEY } from '@/lib/base-path';
const normalizeBasePath = (value) => {
if (!value || !value.trim()) {
return '/';
}
let normalized = value.trim();
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, '');
return normalized || '/';
};
const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
---
<script is:inline>
<script is:inline define:vars={{ BASE_PATH_WINDOW_KEY, runtimeBasePath }}>
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
}
// "system" or nothing stored: follow the OS.
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>
+93
View File
@@ -0,0 +1,93 @@
import * as React from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ThemePreference = "light" | "dark" | "system";
const OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
function resolveIsDark(preference: ThemePreference): boolean {
if (preference === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return preference === "dark";
}
/** Icon-only segmented theme control for the sidebar. Persists the raw
* preference (including "system") so it survives reloads. */
export function ThemeSwitcher({ className }: { className?: string }) {
const [preference, setPreference] = React.useState<ThemePreference>("system");
React.useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark" || stored === "system") {
setPreference(stored);
}
}, []);
React.useEffect(() => {
document.documentElement.classList[
resolveIsDark(preference) ? "add" : "remove"
]("dark");
if (preference !== "system") return;
// Follow OS changes live while "system" is selected.
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () =>
document.documentElement.classList[media.matches ? "add" : "remove"]("dark");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [preference]);
const select = (value: ThemePreference) => {
setPreference(value);
localStorage.setItem("theme", value);
};
return (
<div
className={cn("flex gap-0.5 rounded-lg bg-muted p-0.5", className)}
role="radiogroup"
aria-label="Theme"
>
<TooltipProvider>
{OPTIONS.map((option) => {
const Icon = option.icon;
const active = preference === option.value;
return (
<Tooltip key={option.value} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-checked={active}
aria-label={option.label}
onClick={() => select(option.value)}
className={cn(
"flex h-6.5 w-7.5 items-center justify-center rounded-md transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{option.label}</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const MarqueeHoverContext = createContext<boolean | null>(null);
/**
* Shared hover target: every MarqueeText inside starts scrolling when the
* pointer is anywhere over this element, so the hit area isn't limited to
* the text itself.
*/
export function MarqueeTrigger({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const [hovering, setHovering] = useState(false);
return (
<div
{...props}
className={className}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<MarqueeHoverContext.Provider value={hovering}>
{children}
</MarqueeHoverContext.Provider>
</div>
);
}
interface MarqueeTextProps {
children: React.ReactNode;
className?: string;
/** Scroll speed in px/s while hovered */
speed?: number;
}
/**
* Single-line text that truncates with an ellipsis and scrolls the hidden
* portion into view on hover (sliding back on mouse leave). The scroll is a
* compositor-driven transform for smoothness; the content stays plain inline
* text while at rest because native ellipsis rendering doesn't work on the
* inline-block wrapper the transform needs.
*/
export function MarqueeText({ children, className, speed = 60 }: MarqueeTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLSpanElement>(null);
const groupHovering = useContext(MarqueeHoverContext);
const [selfHovering, setSelfHovering] = useState(false);
const hovering = groupHovering ?? selfHovering;
const [shift, setShift] = useState(0);
const [scrolling, setScrolling] = useState(false);
const [resting, setResting] = useState(true);
useEffect(() => {
if (hovering) {
const container = containerRef.current;
if (!container) return;
// Measured on every hover so virtualized rows and resizes stay accurate
const overflow = Math.max(0, container.scrollWidth - container.clientWidth);
if (overflow === 0) return;
setShift(overflow);
setResting(false);
// Start the transform a frame after the inline-block swap commits so
// the transition animates instead of jumping to the end position
const raf = requestAnimationFrame(() =>
requestAnimationFrame(() => setScrolling(true))
);
return () => cancelAnimationFrame(raf);
}
setScrolling(false);
// If the text never actually moved (left before the start delay ended),
// no transition runs and no transitionend will fire — rest immediately
const content = contentRef.current;
const transform = content ? getComputedStyle(content).transform : "none";
if (transform === "none" || transform === "matrix(1, 0, 0, 1, 0, 0)") {
setResting(true);
}
}, [hovering]);
return (
<div
ref={containerRef}
onMouseEnter={groupHovering === null ? () => setSelfHovering(true) : undefined}
onMouseLeave={groupHovering === null ? () => setSelfHovering(false) : undefined}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
style={{ textOverflow: resting ? "ellipsis" : "clip" }}
>
<span
ref={contentRef}
onTransitionEnd={(e) => {
if (e.propertyName === "transform" && !scrolling) setResting(true);
}}
style={
resting
? undefined
: {
display: "inline-block",
willChange: "transform",
transform: scrolling ? `translateX(-${shift}px)` : "translateX(0)",
transition: scrolling
? `transform ${shift / speed}s linear 0.3s`
: "transform 0.3s ease-out",
}
}
>
{children}
</span>
</div>
);
}
+4 -3
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
import { withBase } from "@/lib/base-path";
interface AuthContextType {
user: ExtendedUser | null;
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
window.location.href = withBase("/signup");
} else {
window.location.href = "/login";
window.location.href = withBase("/login");
}
console.error("Auth check failed", err);
} finally {
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
window.location.href = withBase("/login");
} catch (err) {
console.error("Logout error:", err);
} finally {
+5 -4
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
interface AuthContextType {
user: AuthUser | null;
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const result = await authClient.signIn.email({
email,
password,
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email,
password,
name: username, // Better Auth uses 'name' field for display name
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = "/login";
window.location.href = withBase("/login");
},
},
});
@@ -140,4 +141,4 @@ export function useAuth() {
}
// Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession };
export { useBetterAuthSession };
+14 -10
View File
@@ -65,14 +65,16 @@ export function useConfigStatus(): ConfigStatus {
if (isCacheValid && hasCheckedRef.current) {
const configResponse = configCache.data!;
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
@@ -108,14 +110,16 @@ export function useConfigStatus(): ConfigStatus {
userId: user.id
};
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
+21 -1
View File
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from './useAuth';
import { apiRequest } from '@/lib/utils';
import type { ConfigApiResponse, GiteaConfig } from '@/types/config';
import type { AdvancedOptions, ConfigApiResponse, GiteaConfig, MirrorOptions } from '@/types/config';
import { getCachedConfig } from './useConfigStatus';
interface GiteaConfigHook {
giteaConfig: GiteaConfig | null;
/**
* Advanced options from the same config payload. Exposed so callers can read
* starredCodeOnly without a second request.
*/
advancedOptions: AdvancedOptions | null;
/**
* Mirror flags in the API's reshaped form. The mirror flags are NOT present
* on giteaConfig in this payload; use mirrorOptionsToFlags() to read them.
*/
mirrorOptions: MirrorOptions | null;
isLoading: boolean;
error: string | null;
}
@@ -18,6 +28,8 @@ export function useGiteaConfig(): GiteaConfigHook {
const { user } = useAuth();
const [giteaConfigState, setGiteaConfigState] = useState<GiteaConfigHook>({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: true,
error: null,
});
@@ -26,6 +38,8 @@ export function useGiteaConfig(): GiteaConfigHook {
if (!user?.id) {
setGiteaConfigState({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: false,
error: 'User not authenticated',
});
@@ -37,6 +51,8 @@ export function useGiteaConfig(): GiteaConfigHook {
if (cachedConfig) {
setGiteaConfigState({
giteaConfig: cachedConfig.giteaConfig || null,
advancedOptions: cachedConfig.advancedOptions || null,
mirrorOptions: cachedConfig.mirrorOptions || null,
isLoading: false,
error: null,
});
@@ -53,12 +69,16 @@ export function useGiteaConfig(): GiteaConfigHook {
setGiteaConfigState({
giteaConfig: configResponse?.giteaConfig || null,
advancedOptions: configResponse?.advancedOptions || null,
mirrorOptions: configResponse?.mirrorOptions || null,
isLoading: false,
error: null,
});
} catch (error) {
setGiteaConfigState({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch Gitea configuration',
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react";
import type { MirrorJob } from "@/lib/db/schema";
import { withBase } from "@/lib/base-path";
interface UseSSEOptions {
userId?: string;
@@ -41,7 +42,7 @@ export const useSSE = ({
}
// Create new EventSource connection
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent) => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { useAuth } from "./useAuth";
import { withBase } from "@/lib/base-path";
interface UseRepoSyncOptions {
userId?: string;
@@ -51,7 +52,7 @@ export function useRepoSync({
const sync = async () => {
try {
const response = await fetch("/api/job/schedule-sync-repo", {
const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
method: "POST",
headers: {
"Content-Type": "application/json",
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useSyncExternalStore } from "react";
import {
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
type TimeFormatPreference,
} from "@/lib/utils/time-format";
const getServerSnapshot = (): TimeFormatPreference => "auto";
/**
* Subscribe a component to the user's 12h/24h time format preference.
*
* Any component that renders clock times should call this hook (even if it
* only needs the re-render) so timestamps update immediately when the user
* changes the preference — navigation is SPA-style, so components stay
* mounted across pages.
*/
export function useTimeFormat() {
const preference = useSyncExternalStore(
subscribeToTimeFormatChange,
getTimeFormatPreference,
getServerSnapshot
);
const setPreference = useCallback((next: TimeFormatPreference) => {
setTimeFormatPreference(next);
}, []);
return { preference, setPreference };
}
+3 -2
View File
@@ -2,6 +2,7 @@
import '../styles/global.css';
import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props;
@@ -10,8 +11,8 @@ const { title = 'Gitea Mirror' } = Astro.props;
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<title>{title}</title>
<ThemeScript />
</head>
+21 -5
View File
@@ -1,5 +1,7 @@
import { withBase } from "@/lib/base-path";
// Base API URL
const API_BASE = "/api";
const API_BASE = withBase("/api");
// Helper function for API requests
async function apiRequest<T>(
@@ -78,15 +80,29 @@ export const githubApi = {
method: "POST",
body: JSON.stringify({ token }),
}),
getStarredLists: () =>
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
method: "GET",
}),
};
// Gitea API
export interface GiteaServerInfo {
type: "forgejo" | "gitea";
version: string;
raw: string;
hasMirrorCredBug: boolean;
}
export const giteaApi = {
testConnection: (url: string, token: string) =>
apiRequest<{ success: boolean }>("/gitea/test-connection", {
method: "POST",
body: JSON.stringify({ url, token }),
}),
apiRequest<{ success: boolean; serverInfo?: GiteaServerInfo; message?: string }>(
"/gitea/test-connection",
{
method: "POST",
body: JSON.stringify({ url, token }),
}
),
};
// Health API
+11 -6
View File
@@ -1,8 +1,14 @@
import "@/lib/polyfills/buffer";
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
import { withBase } from "@/lib/base-path";
function normalizeAuthBaseUrl(url: string): string {
const validatedUrl = new URL(url.trim());
return validatedUrl.origin;
}
export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
// Validate URL format and preserve optional base path
return normalizeAuthBaseUrl(url);
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
@@ -34,9 +39,9 @@ export const authClient = createAuthClient({
// Default for SSR - always return a valid URL
return 'http://localhost:4321';
})(),
basePath: '/api/auth', // Explicitly set the base path
basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [
oidcClient(),
oauthProviderClient(),
ssoClient(),
],
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, mock, test, beforeEach } from "bun:test";
// Stub `./auth` so we can drive the response shape `mintSessionFromHeaders`
// sees from the plugin endpoint without standing up the full Better Auth
// stack + DB.
const signInWithHeaderMock = mock<(args: unknown) => Promise<Response>>(
async () => new Response(null, { status: 500 }),
);
mock.module("@/lib/auth", () => ({
auth: {
api: {
signInWithHeader: signInWithHeaderMock,
},
},
}));
import { mintSessionFromHeaders } from "./auth-header-bridge";
function makeRequest(headers: Record<string, string> = {}): Request {
return new Request("http://localhost/test", { headers });
}
describe("mintSessionFromHeaders", () => {
beforeEach(() => {
signInWithHeaderMock.mockReset();
});
test("returns user, session, and Set-Cookie values on a 200 response", async () => {
signInWithHeaderMock.mockImplementation(async () => {
const response = new Response(
JSON.stringify({
user: { id: "user-1", email: "u@example.com" },
session: { id: "sess-1", userId: "user-1" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
// Modern runtimes coalesce multiple Set-Cookie via append.
response.headers.append("set-cookie", "better-auth-session=abc; Path=/");
response.headers.append("set-cookie", "better-auth-remember=1; Path=/");
return response;
});
const result = await mintSessionFromHeaders(makeRequest());
expect(result).not.toBeNull();
expect(result?.user.id).toBe("user-1");
expect(result?.session.id).toBe("sess-1");
expect(result?.setCookies).toEqual([
"better-auth-session=abc; Path=/",
"better-auth-remember=1; Path=/",
]);
});
test("returns null when the endpoint responds non-2xx (header auth disabled / unauthorized)", async () => {
signInWithHeaderMock.mockImplementation(
async () => new Response("Unauthorized", { status: 401 }),
);
const result = await mintSessionFromHeaders(makeRequest());
expect(result).toBeNull();
});
test("returns null when the endpoint throws (transient failure)", async () => {
signInWithHeaderMock.mockImplementation(async () => {
throw new Error("upstream unreachable");
});
const result = await mintSessionFromHeaders(makeRequest());
expect(result).toBeNull();
});
test("returns null when the response body is missing user or session", async () => {
signInWithHeaderMock.mockImplementation(
async () =>
new Response(JSON.stringify({ token: "abc" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await mintSessionFromHeaders(makeRequest());
expect(result).toBeNull();
});
test("returns null when the body is malformed JSON", async () => {
signInWithHeaderMock.mockImplementation(
async () =>
new Response("not json at all", {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await mintSessionFromHeaders(makeRequest());
expect(result).toBeNull();
});
test("returns an empty setCookies array when no Set-Cookie headers were attached", async () => {
// Defensive — should never happen in practice because the plugin
// always calls setSessionCookie. If it does happen, we still want
// the user/session to come through so SSR works on this request.
signInWithHeaderMock.mockImplementation(
async () =>
new Response(
JSON.stringify({
user: { id: "user-1" },
session: { id: "sess-1" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const result = await mintSessionFromHeaders(makeRequest());
expect(result).not.toBeNull();
expect(result?.setCookies).toEqual([]);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { auth } from "./auth";
export interface BridgeResult {
user: any;
session: any;
setCookies: string[];
}
/**
* Calls the `header-auth` plugin endpoint to mint a real Better Auth
* session from trusted upstream headers (Authentik / Authelia /
* oauth2-proxy / Caddy), and returns the user, session, and the
* `Set-Cookie` headers for the middleware to forward onto the
* outbound response.
*
* Fail-open: returns null on any failure (endpoint disabled, headers
* missing, DB blip, malformed response). The middleware then sets
* locals to null and the request proceeds as anonymous — broken
* header auth must never lock everyone out of the cookie-auth path.
*
* Cookie extraction prefers `Response.headers.getSetCookie()` (Node 18+
* fetch, undici). Older runtimes that only expose `get('set-cookie')`
* fall through to the single-header form; that path coalesces all
* Set-Cookie values into one comma-separated string, which is wrong
* for cookies whose attributes contain commas (`Expires` does). We
* accept that risk because: (a) Bun and the supported Node versions
* for this project both implement `getSetCookie`, and (b) the
* fallback only fires on truly ancient runtimes that aren't in our
* support matrix.
*/
export async function mintSessionFromHeaders(
request: Request,
): Promise<BridgeResult | null> {
try {
const response = await auth.api.signInWithHeader({
headers: request.headers,
asResponse: true,
});
if (!response.ok) return null;
const data = await response.json().catch(() => null);
if (!data?.user || !data?.session) return null;
const setCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: response.headers.get("set-cookie")
? [response.headers.get("set-cookie") as string]
: [];
return { user: data.user, session: data.session, setCookies };
} catch {
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { headerAuthPlugin } from "./auth-header-plugin";
describe("headerAuthPlugin", () => {
test("registers the `header-auth` plugin id", () => {
const plugin = headerAuthPlugin();
expect(plugin.id).toBe("header-auth");
});
test("exposes a `signInWithHeader` endpoint", () => {
const plugin = headerAuthPlugin();
expect(plugin.endpoints?.signInWithHeader).toBeDefined();
});
test("mounts the endpoint at POST /sign-in/header", () => {
// The Astro API route is /api/auth/<plugin-path>, so the resolved
// URL the middleware bridge talks to is /api/auth/sign-in/header.
// Locking that path down in a test prevents an accidental rename
// from silently breaking the React SPA's auth flow.
const plugin = headerAuthPlugin();
const endpoint = plugin.endpoints?.signInWithHeader as unknown as {
path: string;
options: { method: string };
};
expect(endpoint.path).toBe("/sign-in/header");
expect(endpoint.options.method).toBe("POST");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { BetterAuthPlugin } from "better-auth";
import { APIError, createAuthEndpoint } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import { authenticateWithHeaders, isHeaderAuthEnabled } from "./auth-header";
/**
* Better Auth plugin that bridges header / forward authentication into a
* real Better Auth session.
*
* Why this exists: the Astro middleware historically populated
* `context.locals.user` from trusted upstream headers (Authentik /
* Authelia / oauth2-proxy / Caddy), but never minted a Better Auth
* session. Server-rendered pages saw the user, but the React SPA's
* `/api/auth/get-session` call hit Better Auth's handler — which only
* reads its session cookie — and got `null`. The auth guard then
* bounced to `/login`, so header auth was end-to-end broken.
*
* This plugin exposes `POST /sign-in/header`, which the middleware
* calls once per cold request (no cookie yet) when header auth is
* enabled. It verifies the trusted headers, creates a real session
* row via `internalAdapter.createSession`, and attaches the
* `Set-Cookie` to the response. The middleware then forwards that
* cookie to the outbound Astro response, so the SPA's next call to
* `get-session` carries the cookie and works.
*
* The endpoint trusts whatever upstream sets the configured headers —
* the security model here is "the operator controls the reverse
* proxy." Make sure the proxy strips inbound copies of these headers
* before forwarding (documented in docs/SSO-OIDC-SETUP.md).
*/
export const headerAuthPlugin = () =>
({
id: "header-auth",
endpoints: {
signInWithHeader: createAuthEndpoint(
"/sign-in/header",
{ method: "POST" },
async (ctx) => {
if (!isHeaderAuthEnabled()) {
throw new APIError("NOT_FOUND", {
message: "Header authentication is not enabled",
});
}
const headers = ctx.request?.headers ?? ctx.headers;
if (!headers) {
throw new APIError("BAD_REQUEST", {
message: "Request headers unavailable",
});
}
const user = await authenticateWithHeaders(headers);
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: "Header authentication failed",
});
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "Failed to create session",
});
}
await setSessionCookie(ctx, { session, user });
return ctx.json({
token: session.token,
user,
session,
});
},
),
},
}) satisfies BetterAuthPlugin;
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import {
extractUserFromHeaders,
isHeaderAuthEnabled,
getHeaderAuthConfig,
} from "./auth-header";
// `auth-header` reads config from `process.env` at call time. We snapshot
// the relevant keys and restore them after each test so cases don't bleed.
const HEADER_ENV_KEYS = [
"HEADER_AUTH_ENABLED",
"HEADER_AUTH_AUTO_PROVISION",
"HEADER_AUTH_USER_HEADER",
"HEADER_AUTH_EMAIL_HEADER",
"HEADER_AUTH_NAME_HEADER",
"HEADER_AUTH_ALLOWED_DOMAINS",
] as const;
let savedEnv: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string | undefined>> = {};
function setEnv(vars: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string>>) {
for (const key of HEADER_ENV_KEYS) {
if (key in vars) {
process.env[key] = vars[key]!;
} else {
delete process.env[key];
}
}
}
beforeEach(() => {
savedEnv = Object.fromEntries(
HEADER_ENV_KEYS.map((k) => [k, process.env[k]]),
) as typeof savedEnv;
});
afterEach(() => {
for (const key of HEADER_ENV_KEYS) {
const v = savedEnv[key];
if (v === undefined) delete process.env[key];
else process.env[key] = v;
}
});
describe("isHeaderAuthEnabled", () => {
test("returns false when HEADER_AUTH_ENABLED is unset", () => {
setEnv({});
expect(isHeaderAuthEnabled()).toBe(false);
});
test("returns false when HEADER_AUTH_ENABLED is anything other than the string 'true'", () => {
setEnv({ HEADER_AUTH_ENABLED: "1" });
expect(isHeaderAuthEnabled()).toBe(false);
setEnv({ HEADER_AUTH_ENABLED: "yes" });
expect(isHeaderAuthEnabled()).toBe(false);
});
test("returns true only for HEADER_AUTH_ENABLED='true' exactly", () => {
setEnv({ HEADER_AUTH_ENABLED: "true" });
expect(isHeaderAuthEnabled()).toBe(true);
});
});
describe("extractUserFromHeaders", () => {
test("returns null when header auth is disabled", () => {
setEnv({});
const headers = new Headers({ "X-Authentik-Username": "u" });
expect(extractUserFromHeaders(headers)).toBeNull();
});
test("returns null when the configured user header is absent", () => {
setEnv({ HEADER_AUTH_ENABLED: "true" });
const headers = new Headers({ "X-Some-Other-Header": "u" });
expect(extractUserFromHeaders(headers)).toBeNull();
});
test("returns username, email, and name from default Authentik headers", () => {
setEnv({ HEADER_AUTH_ENABLED: "true" });
const headers = new Headers({
"X-Authentik-Username": "alice",
"X-Authentik-Email": "alice@example.com",
"X-Authentik-Name": "Alice Q",
});
expect(extractUserFromHeaders(headers)).toEqual({
username: "alice",
email: "alice@example.com",
name: "Alice Q",
});
});
test("respects HEADER_AUTH_USER_HEADER override (Caddy / caddy-security style)", () => {
setEnv({
HEADER_AUTH_ENABLED: "true",
HEADER_AUTH_USER_HEADER: "X-Token-User-Email",
HEADER_AUTH_EMAIL_HEADER: "X-Token-User-Email",
HEADER_AUTH_NAME_HEADER: "X-Token-User-Name",
});
const headers = new Headers({
"X-Token-User-Email": "bob@example.com",
"X-Token-User-Name": "Bob",
});
// lanrat's reported config: username and email are both pulled from
// the same header. Both should resolve to that value.
expect(extractUserFromHeaders(headers)).toEqual({
username: "bob@example.com",
email: "bob@example.com",
name: "Bob",
});
});
test("rejects when email domain is not on the allow list", () => {
setEnv({
HEADER_AUTH_ENABLED: "true",
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
});
const headers = new Headers({
"X-Authentik-Username": "evil",
"X-Authentik-Email": "evil@elsewhere.test",
});
expect(extractUserFromHeaders(headers)).toBeNull();
});
test("accepts when email domain matches the allow list", () => {
setEnv({
HEADER_AUTH_ENABLED: "true",
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
});
const headers = new Headers({
"X-Authentik-Username": "alice",
"X-Authentik-Email": "alice@corp.example",
});
expect(extractUserFromHeaders(headers)).toEqual({
username: "alice",
email: "alice@corp.example",
name: undefined,
});
});
});
describe("getHeaderAuthConfig", () => {
test("merges env overrides over defaults without leaking unset env values", () => {
setEnv({
HEADER_AUTH_ENABLED: "true",
HEADER_AUTH_USER_HEADER: "X-Forwarded-User",
});
const config = getHeaderAuthConfig();
expect(config.enabled).toBe(true);
expect(config.userHeader).toBe("X-Forwarded-User");
// Unset overrides should fall back to defaults, not become undefined.
expect(config.emailHeader).toBe("X-Authentik-Email");
expect(config.nameHeader).toBe("X-Authentik-Name");
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { resolveTrustedOrigins, extractSsoProviderOrigins } from "./auth";
// Helper to create a mock Request with specific headers
function mockRequest(headers: Record<string, string>): Request {
return new Request("http://localhost:4321/api/auth/sign-in", {
headers: new Headers(headers),
});
}
describe("resolveTrustedOrigins", () => {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
// Save and clear relevant env vars
for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
// Restore env vars
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
});
test("includes localhost defaults when called without request", async () => {
const origins = await resolveTrustedOrigins();
expect(origins).toContain("http://localhost:4321");
expect(origins).toContain("http://localhost:8080");
});
test("includes BETTER_AUTH_URL from env", async () => {
process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).toContain("https://gitea-mirror.example.com");
});
test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => {
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).toContain("https://a.example.com");
expect(origins).toContain("https://b.example.com");
});
test("skips invalid URLs in env vars", async () => {
process.env.BETTER_AUTH_URL = "not-a-url";
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com";
const origins = await resolveTrustedOrigins();
expect(origins).not.toContain("not-a-url");
expect(origins).not.toContain("also-invalid");
expect(origins).toContain("https://valid.example.com");
});
test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea-mirror.mydomain.tld",
"x-forwarded-proto": "https",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
});
test("falls back to host header when x-forwarded-host is absent", async () => {
const req = mockRequest({
host: "myserver.local:4321",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("http://myserver.local:4321");
});
test("handles multi-value x-forwarded-host (chained proxies)", async () => {
const req = mockRequest({
"x-forwarded-host": "external.example.com, internal.proxy.local",
"x-forwarded-proto": "https",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://external.example.com");
expect(origins).not.toContain("https://internal.proxy.local");
});
test("handles multi-value x-forwarded-proto (chained proxies)", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.example.com",
"x-forwarded-proto": "https, http",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("https://gitea.example.com");
// Should NOT create an origin with "https, http" as proto
expect(origins).not.toContain("https, http://gitea.example.com");
});
test("rejects invalid x-forwarded-proto values", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.example.com",
"x-forwarded-proto": "ftp",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).not.toContain("ftp://gitea.example.com");
});
test("deduplicates origins", async () => {
process.env.BETTER_AUTH_URL = "http://localhost:4321";
const origins = await resolveTrustedOrigins();
const count = origins.filter(o => o === "http://localhost:4321").length;
expect(count).toBe(1);
});
test("defaults proto to http when x-forwarded-proto is absent", async () => {
const req = mockRequest({
"x-forwarded-host": "gitea.internal",
});
const origins = await resolveTrustedOrigins(req);
expect(origins).toContain("http://gitea.internal");
});
});
/**
* Regression coverage for issue #366: better-auth 1.6.23's SSO plugin
* refuses OIDC discovery from untrusted origins and rejects endpoints whose
* hostnames resolve to private addresses unless the origin is in
* trustedOrigins. Operator-registered providers are trusted automatically;
* extractSsoProviderOrigins is the pure extraction step feeding that.
*/
describe("extractSsoProviderOrigins", () => {
test("extracts the issuer origin, stripping any path", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "https://auth.example.dev/application/o/gitea-mirror/", oidcConfig: null },
]);
expect(origins).toContain("https://auth.example.dev");
expect(origins).not.toContain("https://auth.example.dev/application/o/gitea-mirror/");
});
test("extracts endpoint origins from oidcConfig, including hosts that differ from the issuer", () => {
const origins = extractSsoProviderOrigins([
{
issuer: "https://auth.example.dev",
oidcConfig: JSON.stringify({
discoveryEndpoint: "https://auth.example.dev/.well-known/openid-configuration",
authorizationEndpoint: "https://auth.example.dev/authorize",
tokenEndpoint: "https://token.internal.example:8443/oauth/token",
userInfoEndpoint: "https://auth.example.dev/userinfo",
jwksEndpoint: "https://keys.example.dev/jwks",
}),
},
]);
expect(origins).toContain("https://auth.example.dev");
expect(origins).toContain("https://token.internal.example:8443");
expect(origins).toContain("https://keys.example.dev");
});
test("malformed oidcConfig JSON still yields the issuer origin without throwing", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "https://auth.example.dev", oidcConfig: "{not json" },
]);
expect(origins).toContain("https://auth.example.dev");
});
test("skips non-URL values such as SAML entity IDs", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "urn:example:idp", oidcConfig: null },
]);
expect(origins).toEqual([]);
});
test("handles empty and null fields without throwing", () => {
expect(extractSsoProviderOrigins([])).toEqual([]);
expect(extractSsoProviderOrigins([{ issuer: null, oidcConfig: null }])).toEqual([]);
expect(extractSsoProviderOrigins([{ issuer: "", oidcConfig: "" }])).toEqual([]);
});
});
+264 -58
View File
@@ -1,10 +1,171 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { oidcProvider } from "better-auth/plugins";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
import { sso } from "@better-auth/sso";
import { db, users } from "./db";
import { db, users, ssoProviders } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
import { headerAuthPlugin } from "./auth-header-plugin";
/**
* Extracts the origins implied by registered SSO identity providers.
*
* The SSO plugin (since better-auth 1.6.23, shipped in v3.21.0) hardens
* sign-in against SSRF: it refuses to fetch OIDC discovery documents from
* untrusted origins and rejects token/userinfo/jwks endpoints whose
* hostnames resolve to private addresses — unless the origin is listed in
* `trustedOrigins`, the documented escape hatch for internal IdPs. Homelab
* deployments with split-horizon DNS (auth.example.com resolving to a LAN
* IP from inside the container) hit this on every sign-in, which the login
* UI used to swallow silently (#366).
*
* Registering a provider is an explicit operator action — the same trust
* model behind the `domainVerified` flag we set at registration — so the
* provider's issuer and endpoint origins are trusted automatically instead
* of requiring BETTER_AUTH_TRUSTED_ORIGINS to be set by hand. Note this
* also lets those origins pass Better Auth's CSRF/redirect-target checks,
* which is acceptable for operator-registered IdP infrastructure.
*
* Exported for testing.
*/
export function extractSsoProviderOrigins(
rows: Array<{ issuer?: string | null; oidcConfig?: string | null }>
): string[] {
const origins: string[] = [];
for (const row of rows) {
const candidates: unknown[] = [row?.issuer];
if (typeof row?.oidcConfig === "string" && row.oidcConfig.trim() !== "") {
try {
const cfg = JSON.parse(row.oidcConfig);
candidates.push(
cfg?.discoveryEndpoint,
cfg?.authorizationEndpoint,
cfg?.tokenEndpoint,
cfg?.userInfoEndpoint,
cfg?.jwksEndpoint,
);
} catch {
// A malformed provider row must not break auth for everyone;
// the issuer candidate above still applies.
}
}
for (const candidate of candidates) {
if (typeof candidate !== "string" || candidate.trim() === "") continue;
try {
const url = new URL(candidate);
// Only http(s) URLs have a meaningful origin — new URL() accepts
// values like SAML entity IDs ("urn:...") without throwing, but
// their .origin is the literal string "null".
if (url.protocol === "http:" || url.protocol === "https:") {
origins.push(url.origin);
}
} catch {
// Skip values that aren't URLs at all.
}
}
}
return origins;
}
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
* Exported for testing. Called per-request with the incoming Request,
* or at startup with no request (static origins only).
*/
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
const origins: string[] = [
"http://localhost:4321",
"http://localhost:8080", // Keycloak
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Trust the origins of operator-registered SSO identity providers so the
// SSO plugin's SSRF checks accept them without manual env configuration.
// See extractSsoProviderOrigins for the rationale.
try {
const providerRows = await db
.select({ issuer: ssoProviders.issuer, oidcConfig: ssoProviders.oidcConfig })
.from(ssoProviders);
if (Array.isArray(providerRows)) {
origins.push(...extractSsoProviderOrigins(providerRows));
}
} catch {
// Fresh DB before migration or DB briefly unavailable — fall back to
// env-configured origins rather than failing auth outright.
}
// Auto-detect origin from the incoming request's Host header when running
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
if (request?.headers) {
// Take first value only — headers can be comma-separated in chained proxy setups
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
const host = rawHost?.split(",")[0].trim();
if (host) {
const rawProto = request.headers.get("x-forwarded-proto") || "http";
const proto = rawProto.split(",")[0].trim().toLowerCase();
if (proto === "http" || proto === "https") {
try {
const detected = new URL(`${proto}://${host}`);
origins.push(detected.origin);
} catch {
// Malformed header, ignore
}
}
}
}
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
if (!request) {
console.info("Trusted origins (static):", uniqueOrigins);
}
return uniqueOrigins;
}
/**
* Resolves the Better Auth logger level from BETTER_AUTH_LOG_LEVEL.
* Returns undefined for unset/invalid values so Better Auth falls back
* to its built-in default ("warn"). "success" is intentionally excluded
* — it is an output level, not a valid threshold.
*/
function resolveAuthLogLevel(): "debug" | "info" | "warn" | "error" | undefined {
const raw = process.env.BETTER_AUTH_LOG_LEVEL?.trim().toLowerCase();
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
return raw;
}
if (raw) {
console.warn(`Invalid BETTER_AUTH_LOG_LEVEL: "${raw}", using default ("warn")`);
}
return undefined;
}
export const auth = betterAuth({
// Database configuration
@@ -17,6 +178,17 @@ export const auth = betterAuth({
// Secret for signing tokens
secret: process.env.BETTER_AUTH_SECRET,
// Logger configuration.
//
// Better Auth ships its own logger (it does NOT read the `DEBUG` env
// var / the `debug` npm package — `DEBUG=better-auth:*` is a no-op).
// The default level is "warn", so SSO/OIDC debug and info messages
// are hidden out of the box. Set BETTER_AUTH_LOG_LEVEL=debug to surface
// the full sign-in / callback trace when troubleshooting SSO.
logger: {
level: resolveAuthLogLevel(),
},
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
baseURL: (() => {
const url = process.env.BETTER_AUTH_URL;
@@ -31,7 +203,7 @@ export const auth = betterAuth({
try {
// Validate URL format and ensure it's a proper origin
const validatedUrl = new URL(url.trim());
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
const cleanUrl = validatedUrl.origin;
console.info('Using BETTER_AUTH_URL:', cleanUrl);
return cleanUrl;
} catch (e) {
@@ -41,50 +213,13 @@ export const auth = betterAuth({
return defaultUrl;
}
})(),
basePath: "/api/auth", // Specify the base path for auth endpoints
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs
trustedOrigins: (() => {
const origins: string[] = [
"http://localhost:4321",
"http://localhost:8080", // Keycloak
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
// This is where users can specify multiple access URLs
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
// Validate each additional origin
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Remove duplicates and empty strings, then return
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
console.info('Trusted origins:', uniqueOrigins);
return uniqueOrigins;
})(),
// Trusted origins - this is how we support multiple access URLs.
// Uses the function form so that the origin can be auto-detected from
// the incoming request's Host / X-Forwarded-* headers, which makes the
// app work behind a reverse proxy without manual env var configuration.
trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
// Authentication methods
emailAndPassword: {
@@ -115,28 +250,79 @@ export const auth = betterAuth({
input: false, // Don't show in signup form - we'll derive from email
}
},
changeEmail: {
enabled: true,
// Email verification isn't wired up (sendResetPassword is a TODO),
// so allow direct updates. Safe here because emails stay unverified.
updateEmailWithoutVerification: true,
},
},
// Account linking configuration.
//
// Lets a user who first registered with email/password sign in through SSO
// and land on the *same* account, instead of being bounced back to /login.
// Better Auth's auto-link path (link-account.mjs) refuses unless BOTH sides
// pass:
// - upstream: provider is "trusted" (either listed in `trustedProviders`
// or the SSO plugin marks it trusted via domainVerified +
// domain match) OR userInfo.emailVerified === true
// - local: existing user is verified (or requireLocalEmailVerified=false)
//
// We don't wire an email-verification flow, so the local admin always has
// emailVerified=false — `requireLocalEmailVerified: false` is required.
//
// We deliberately do NOT use the catch-all `trustedProviders` list (which
// would blanket-trust every registered IdP). Instead the SSO provider's
// own `domainVerified` flag — set to true at registration time, scoped to
// the operator-supplied `domain` — gates linking. The SSO plugin enforces
// `validateEmailDomain(userInfo.email, provider.domain)` on top of it, so
// a sign-in is only auto-linked when (a) the operator vouched for the IdP
// by registering it, and (b) the user's email actually belongs to the
// domain that was vouched for. Cross-domain claims from a compromised or
// permissive IdP do not silently absorb local accounts. (Same-domain
// claims still require the operator to trust their IdP's identity model.)
account: {
accountLinking: {
enabled: true,
requireLocalEmailVerified: false,
// Keep the default (false): never link accounts whose emails differ.
allowDifferentEmails: false,
},
},
// Plugins configuration
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
// JWT plugin — provides the JWKS keypair that the OAuth provider uses to
// sign OIDC id_tokens with an asymmetric key (what most relying parties
// expect). Required by @better-auth/oauth-provider unless disableJwtPlugin
// is set. Keys are persisted in the `jwks` table.
jwt(),
// OAuth 2.1 / OIDC Provider — allows this app to act as an identity
// provider for other applications. Replaces the deprecated `oidcProvider`
// plugin (which Better Auth will remove in a future release). Client and
// consent records now live in the oauth_clients / oauth_consents tables.
oauthProvider({
loginPage: withBase("/login"),
consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it
// For now, we'll use dynamic registration
// Customize user info claims based on scopes
getAdditionalUserInfoClaim: (user, scopes) => {
// Mirror the old getAdditionalUserInfoClaim: expose our extra `username`
// field on both the userinfo endpoint and the id_token when the
// "profile" scope is granted.
customUserInfoClaims: ({ user, scopes }) => {
const claims: Record<string, any> = {};
if (scopes.includes("profile")) {
claims.username = user.username;
}
if (scopes.includes("profile")) claims.username = (user as any).username;
return claims;
},
customIdTokenClaims: ({ user, scopes }) => {
const claims: Record<string, any> = {};
if (scopes.includes("profile")) claims.username = (user as any).username;
return claims;
},
}),
// SSO plugin - allows users to authenticate with external OIDC providers
sso({
// Provision new users when they sign in with SSO
@@ -168,7 +354,27 @@ export const auth = betterAuth({
disableImplicitSignUp: false,
// Trust email_verified claims from the upstream provider so we can link by matching email
trustEmailVerified: true,
// Surface `domainVerified` to the model so account-linking can read it.
//
// Better Auth's adapter transformOutput (factory.mjs) only copies fields
// that are declared in the plugin's model schema; the SSO plugin only
// declares `domainVerified` when this option is enabled. Without it the
// column is in the DB but stripped from the returned provider object, so
// the trust check `"domainVerified" in provider` is silently false and
// sign-ins land on /?error=UNKNOWN. We don't use the DNS-based
// verify-domain flow this option also exposes — we set
// `domainVerified: true` directly in src/pages/api/auth/sso/register.ts
// after the plugin's create, scoped by the operator-supplied `domain`.
domainVerification: { enabled: true },
}),
// Header / forward authentication bridge. Exposes
// POST /api/auth/sign-in/header so the middleware can mint a real
// Better Auth session from trusted upstream headers (Authentik /
// Authelia / oauth2-proxy / Caddy). Without this the SPA's
// /api/auth/get-session call returns null on header-auth-only
// requests and bounces the user to /login. See auth-header-plugin.ts.
headerAuthPlugin(),
],
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test } from "bun:test";
const originalBaseUrl = process.env.BASE_URL;
const originalWindow = (globalThis as { window?: unknown }).window;
async function loadModule(baseUrl?: string, runtimeWindowBasePath?: string) {
if (baseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = baseUrl;
}
if (runtimeWindowBasePath === undefined) {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
const restoredWindow = (globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window;
if (typeof restoredWindow === "object" && restoredWindow !== null) {
delete restoredWindow.__GITEA_MIRROR_BASE_PATH__;
}
}
} else {
(globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window = {
__GITEA_MIRROR_BASE_PATH__: runtimeWindowBasePath,
};
}
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
}
afterEach(() => {
if (originalBaseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = originalBaseUrl;
}
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
});
describe("base-path helpers", () => {
test("defaults to root paths", async () => {
const mod = await loadModule(undefined);
expect(mod.BASE_PATH).toBe("/");
expect(mod.withBase("/api/health")).toBe("/api/health");
expect(mod.withBase("repositories")).toBe("/repositories");
expect(mod.stripBasePath("/config")).toBe("/config");
});
test("normalizes prefixed base paths", async () => {
const mod = await loadModule("mirror/");
expect(mod.BASE_PATH).toBe("/mirror");
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
expect(mod.stripBasePath("/mirror")).toBe("/");
});
test("keeps absolute URLs unchanged", async () => {
const mod = await loadModule("/mirror");
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
});
test("uses browser runtime base path when process env is unset", async () => {
const mod = await loadModule(undefined, "/runtime");
expect(mod.BASE_PATH).toBe("/runtime");
expect(mod.withBase("/api/health")).toBe("/runtime/api/health");
expect(mod.stripBasePath("/runtime/config")).toBe("/config");
});
test("prefers process env base path over browser runtime value", async () => {
const mod = await loadModule("/env", "/runtime");
expect(mod.BASE_PATH).toBe("/env");
expect(mod.withBase("/api/health")).toBe("/env/api/health");
});
});
+83
View File
@@ -0,0 +1,83 @@
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const BASE_PATH_WINDOW_KEY = "__GITEA_MIRROR_BASE_PATH__";
function normalizeBasePath(basePath: string | null | undefined): string {
if (!basePath) {
return "/";
}
let normalized = basePath.trim();
if (!normalized) {
return "/";
}
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
function resolveRuntimeBasePath(): string {
if (typeof process !== "undefined" && typeof process.env?.BASE_URL === "string") {
return normalizeBasePath(process.env.BASE_URL);
}
if (typeof window !== "undefined") {
const runtimeBasePath = (window as Window & { [BASE_PATH_WINDOW_KEY]?: string })[BASE_PATH_WINDOW_KEY];
if (typeof runtimeBasePath === "string") {
return normalizeBasePath(runtimeBasePath);
}
}
return "/";
}
export function getBasePath(): string {
return resolveRuntimeBasePath();
}
export const BASE_PATH = getBasePath();
export { BASE_PATH_WINDOW_KEY };
export function withBase(path: string): string {
const basePath = getBasePath();
if (!path) {
return basePath === "/" ? "/" : `${basePath}/`;
}
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (basePath === "/") {
return normalizedPath;
}
return `${basePath}${normalizedPath}`;
}
export function stripBasePath(pathname: string): string {
const basePath = getBasePath();
if (!pathname) {
return "/";
}
if (basePath === "/") {
return pathname;
}
if (pathname === basePath || pathname === `${basePath}/`) {
return "/";
}
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length) || "/";
}
return pathname;
}
+35 -7
View File
@@ -3,20 +3,38 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
import fs from "fs";
import path from "path";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { repairDuplicateSsoColumns, restoreSsoDataAfter0013 } from "./migration-repairs";
// Skip database initialization in test environment
let db: ReturnType<typeof drizzle>;
/**
* Resolve the SQLite file location from DATABASE_URL, falling back to the
* historical default of <cwd>/data/gitea-mirror.db. Accepts the documented
* sqlite:// scheme, the legacy file: scheme, or a plain path; relative
* paths resolve against the working directory.
*/
function resolveDbPath(): string {
const url = process.env.DATABASE_URL?.trim();
if (!url) {
return path.join(process.cwd(), "data", "gitea-mirror.db");
}
const raw = url.startsWith("sqlite://")
? url.slice("sqlite://".length)
: url.startsWith("file:")
? url.slice("file:".length)
: url;
return path.resolve(raw);
}
if (process.env.NODE_ENV !== "test") {
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
// Ensure data directory exists
const dbPath = resolveDbPath();
const dataDir = path.dirname(dbPath);
// Ensure the directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, "gitea-mirror.db");
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
@@ -95,9 +113,17 @@ if (process.env.NODE_ENV !== "test") {
// Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug)
repairFailedMigrations();
// Fix the v3.17.0 duplicate-column crash: reconcile stranded sso_providers
// columns so migration 0013 can run (see #312). Returns data to re-apply
// once 0013 has re-added the columns.
const preservedSsoData = repairDuplicateSsoColumns(sqlite);
// Run migrations using Drizzle migrate function
migrate(db, { migrationsFolder: "./drizzle" });
// Re-apply any SSO provider data preserved by the repair above.
restoreSsoDataAfter0013(sqlite, preservedSsoData);
console.log("✅ Database migrations completed successfully");
} catch (error) {
console.error("❌ Error running migrations:", error);
@@ -123,9 +149,11 @@ export {
accounts,
verificationTokens,
verifications,
oauthApplications,
oauthClients,
oauthAccessTokens,
oauthConsent,
oauthRefreshTokens,
oauthConsents,
jwkss,
ssoProviders,
rateLimits
} from "./schema";
+117
View File
@@ -0,0 +1,117 @@
import type { Database } from "bun:sqlite";
/**
* Pre-migration repairs that reconcile a database into the exact shape Drizzle's
* migrator expects, so a previously-failed migration can complete on the next
* boot. These run BEFORE `migrate()` and are deliberately defensive: any failure
* is logged and swallowed so they never make a recoverable database worse.
*/
/** Migration 0013 journal timestamp (from drizzle/meta/_journal.json, idx 13). */
const MIGRATION_0013_TIMESTAMP = 1780377747526;
export type PreservedSsoRow = {
id: string;
saml_config?: string | null;
domain_verified?: number;
};
/**
* Repair the v3.17.0 (PR #307) "duplicate column name: saml_config" crash loop
* reported in issue #312.
*
* Some instances ended up with `sso_providers.saml_config` / `domain_verified`
* already present BEFORE migration 0013 ran — the columns were declared in
* schema.ts and entered the DB via `db:push` or an SSO-register round-trip on an
* intermediate build, while `__drizzle_migrations` never recorded a 0013 row.
*
* Migration 0013 runs as a single transaction (organizations rebuild + the two
* `ALTER TABLE sso_providers ADD ...`). The ADD hits the pre-existing column,
* throws "duplicate column", and rolls back the ENTIRE transaction — so 0013 is
* never recorded and is retried, failing identically, on every boot.
*
* This is the mirror image of the 0009 repair in index.ts (record present,
* column missing): here the column is present but the record is missing. We
* reconcile `sso_providers` back to its true pre-0013 shape so the canonical
* 0013 can run in full (the organizations rebuild MUST NOT be skipped),
* preserving any real SAML provider config across the drop/re-add.
*
* Returns the rows whose values must be re-applied by {@link restoreSsoDataAfter0013}
* once 0013 has re-added the columns. Returns an empty array when there is
* nothing to do (fresh install, clean upgrade, or genuine pre-0013 shape).
*/
export function repairDuplicateSsoColumns(sqlite: Database): PreservedSsoRow[] {
try {
const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
.get();
// Fresh install — no migrations recorded yet, vanilla migrate() handles it.
if (!migrationsTableExists) return [];
// 0013 already recorded (clean upgrade / already healed) — nothing to do.
const alreadyApplied = sqlite
.query("SELECT 1 FROM __drizzle_migrations WHERE created_at >= ? LIMIT 1")
.get(MIGRATION_0013_TIMESTAMP);
if (alreadyApplied) return [];
const ssoExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sso_providers'")
.get();
if (!ssoExists) return [];
const cols = sqlite.query("PRAGMA table_info(sso_providers)").all() as { name: string }[];
const hasSaml = cols.some((c) => c.name === "saml_config");
const hasDomainVerified = cols.some((c) => c.name === "domain_verified");
// Genuine pre-0013 shape — let migration 0013 add the columns as-is.
if (!hasSaml && !hasDomainVerified) return [];
console.log(
"🔧 Detected stranded SSO columns (migration 0013 not recorded). Reconciling sso_providers so 0013 can run...",
);
// Preserve any real data before dropping. SAML providers store JSON config
// in saml_config; domain_verified may have been explicitly set to false.
const selectCols = ["id"];
if (hasSaml) selectCols.push("saml_config");
if (hasDomainVerified) selectCols.push("domain_verified");
const preserved = sqlite
.query(`SELECT ${selectCols.join(", ")} FROM sso_providers`)
.all() as PreservedSsoRow[];
// SQLite >= 3.35 (bun:sqlite ships much newer) supports DROP COLUMN.
if (hasSaml) sqlite.run("ALTER TABLE sso_providers DROP COLUMN saml_config");
if (hasDomainVerified) sqlite.run("ALTER TABLE sso_providers DROP COLUMN domain_verified");
// Only rows whose values differ from the 0013 defaults (saml_config NULL,
// domain_verified true/1) need restoring after the columns are re-added.
return preserved.filter(
(r) => (hasSaml && r.saml_config != null) || (hasDomainVerified && r.domain_verified === 0),
);
} catch (error) {
console.warn("⚠️ SSO column repair check failed (non-fatal):", error);
return [];
}
}
/**
* Re-apply the SSO provider values preserved by {@link repairDuplicateSsoColumns}
* once migration 0013 has re-added saml_config / domain_verified with their
* defaults (saml_config NULL, domain_verified = 1). No-op when nothing was
* preserved (the common OIDC-only case).
*/
export function restoreSsoDataAfter0013(sqlite: Database, preserved: PreservedSsoRow[]): void {
if (preserved.length === 0) return;
try {
const stmt = sqlite.prepare(
"UPDATE sso_providers SET saml_config = ?, domain_verified = ? WHERE id = ?",
);
for (const r of preserved) {
stmt.run(r.saml_config ?? null, r.domain_verified ?? 1, r.id);
}
console.log(`✅ Restored ${preserved.length} preserved SSO provider value(s) after migration 0013.`);
} catch (error) {
console.warn("⚠️ Failed to restore preserved SSO data (non-fatal):", error);
}
}
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more