Compare commits

...

205 commits

Author SHA1 Message Date
Nico
7c1f0c52d5
refactor(repo-mutex): use effect.ts (#950)
Some checks failed
Checks / lint (push) Has been cancelled
Checks / typecheck (push) Has been cancelled
Checks / test-server (push) Has been cancelled
Checks / test-client (push) Has been cancelled
Checks / build (push) Has been cancelled
Scorecard analysis / Scorecard analysis (push) Has been cancelled
* refactor(repo-mutex): use effect.ts

* refactor(mutext): run with signal to preserve abort reason
2026-06-06 15:06:26 +02:00
Nico
2318b6bdd0
fix: limit concurrent ls to 2 in flight calls (#948)
* fix: limit concurrent ls to 2 in flight calls

* refactor: get shared lock after semaphore take
2026-06-05 18:07:59 +02:00
Copilot
34fc4f0cbb
docs: update Docker Compose Zerobyte image tags to v0.38 (#945)
* Initial plan

* docs: update Docker Compose Zerobyte image tags to v0.38

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 21:49:15 +02:00
Nico
68002b0308
fix: disable add passkey in insecure contexts (#943)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-06-04 21:06:48 +02:00
Nicolas Meienberger
4bba2c2493
refactor: remove repository lock diagnostics logs 2026-06-04 19:58:02 +02:00
Nicolas Meienberger
9d63f7cb2d
Revert "fix(volumes): decrypt values before testing connection (#939)"
Some checks failed
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
This reverts commit 885ea10f2a.
2026-06-03 19:56:55 +02:00
Nicolas Meienberger
4a4e5c0abe
ci: skip nfs container entirely in github ci 2026-06-03 19:26:45 +02:00
Nico
885ea10f2a
fix(volumes): decrypt values before testing connection (#939)
* fix(volumes): decrypt values before testing connection

* chore: lint issue
2026-06-03 19:05:39 +02:00
Nicolas Meienberger
edab1b231b
fix(restic): validate lock diagnostics object IDs 2026-06-03 18:47:04 +02:00
Nico
333c11986d
feat: enforce protocol version between agent and controller (#938)
* feat: enforce protocol version between agent and controller

* chore: add logging for protocol rejected message
2026-06-03 18:29:36 +02:00
Nicolas Meienberger
be3182793d
fix(agent-backups): validate agent ownership 2026-06-03 17:28:43 +02:00
Nicolas Meienberger
a279129ad8
ci(integration): include only necessary logs in artifacts 2026-06-03 17:21:32 +02:00
Nicolas Meienberger
027c6efb32
ci(docs): allow deploy only on repo default branch 2026-06-03 17:18:10 +02:00
renovate[bot]
755cbe4dae
fix(deps): update bun minor and patch dependencies (#925)
* fix(deps): update bun minor and patch dependencies

* chore: fix linting issues

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-06-03 17:15:53 +02:00
Nicolas Meienberger
111a5843ef
refactor: reject all non uv passkeys 2026-06-03 17:03:00 +02:00
renovate[bot]
49e3977199
fix(deps): update dependency fumadocs-mdx to v15 (#870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:59:25 +02:00
Nicolas Meienberger
ca325a01c5
chore: re-generate openapi client 2026-06-02 21:04:04 +02:00
renovate[bot]
25f60db703
chore(deps): update docker/build-push-action digest to f9f3042 (#910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:25 +02:00
renovate[bot]
478a5fcba3
chore(deps): update docker/login-action digest to 650006c (#913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:03 +02:00
renovate[bot]
bf69fc5f65
chore(deps): update docker/metadata-action digest to 80c7e94 (#915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:42 +02:00
renovate[bot]
0fd88b2cdf
chore(deps): update docker/setup-buildx-action digest to d7f5e7f (#916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:18 +02:00
renovate[bot]
8302893233
fix(deps): update dependency commander to v15 (#932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:47:47 +02:00
Nicolas Meienberger
1526e3d441
test: automate NFS integration 2026-06-02 20:36:33 +02:00
Nicolas Meienberger
dfd787c8ae
test(integration): skip volume mounting in CI 2026-06-02 20:26:50 +02:00
Nico
a488bbc754
fix: block login for 2fa users with un-verified passkeys (#934)
* fix: block login for 2fa users with un-verified passkeys

* refactor(passkey): show proper login error

* refactor: show passkey generic error on all failures
2026-06-02 19:48:40 +02:00
Nicolas Meienberger
ce23bded90
test: automate SMB integration 2026-06-02 19:45:17 +02:00
Nicolas Meienberger
c793785e30
docs: user and role permission model 2026-06-02 19:21:25 +02:00
Nicolas Meienberger
036382d82d
test: automate webdav integration 2026-06-02 17:30:49 +02:00
Nicolas Meienberger
756ecbddcd
test: automate SFTP integration coverage 2026-06-02 17:25:10 +02:00
Nico
648ccae5fc
test(integration): s3 repository with rustfs & rclone (#933)
* test(integration): s3 repository with rustfs

* ci: run integration tests before release

* chore: fix linting issue

* ci: persist-creds -> false
2026-06-01 21:37:12 +02:00
Nicolas Meienberger
62cdf5dcca
fix(backup-config): throw if include patterns have un-supported characters 2026-06-01 20:14:12 +02:00
Nico
00d1dac515
fix(backup-config): throw if include patterns have un-supported chars (#931) 2026-06-01 20:02:07 +02:00
Nico
d479bfaddc
feat: snapshot restores through rpc (#930)
* feat: snapshot restores through rpc

* fix(restore): do not wait for lock before returning response

* chore: fix liniting issue
2026-05-31 21:48:32 +02:00
Nico
8fedeef4d1
feat: add restore agent RPC foundation (#929)
* feat: add restore agent RPC foundation

* chore: temp event handlers

* refactor: export restore progress from dto file
2026-05-31 19:39:20 +02:00
Nico
2d877cee5a
feat: durable tasks (#927) 2026-05-30 16:54:49 +02:00
Nico
0a2c6bca0c
refactor(restic): auto try to unlock and remove stale locks (#926) 2026-05-30 13:21:22 +02:00
Nico
d4436b0cdc
refactor(restic): all commands return effects (#924)
* refactor(restic): all commands are effects

* fix(restic): preserve effect failure errors

* chore: pr feedbacks
2026-05-30 10:10:54 +02:00
Nicolas Meienberger
ddf7cab503 chore(vite): allow tailscale hosts for dev server 2026-05-30 10:09:01 +02:00
renovate[bot]
74ddf574a8
fix(deps): update bun minor and patch dependencies (#902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 22:24:59 +02:00
Nicolas Meienberger
d502b96509
test: fix webhook mocks 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
dee3bb098a
e2e: fix 2fa leakage in next tests 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
5ee65bf0af
refactor(sftp): mixed options style 2026-05-29 20:45:15 +02:00
Nico
7b5c53bb7d
fix(sftp): allow legacy ssh rsa to add support for older servers (#921)
* refactor(e2e): use more stable assertion

* feat(sftp): add legacy ssh-rsa option
2026-05-27 21:19:23 +02:00
Nicolas Meienberger
17a8838569
deps: bump rclone & shoutrrr to latest versions 2026-05-27 21:16:08 +02:00
Nicolas Meienberger
25b2f71b24
e2e: add missing host-gateway mapping 2026-05-27 21:15:03 +02:00
Nico
008296238b
test(e2e): add test suite to test webhooks (#922) 2026-05-27 21:12:53 +02:00
Nicolas Meienberger
2f78c4a1fb
chore: bump nitro & @better-auth/passkey 2026-05-27 21:07:12 +02:00
Nico
20d202c20e
fix(core): enforce webhook timeout (#920) 2026-05-27 20:35:01 +02:00
Nicolas Meienberger
47f8f2f03a
docs: update local dev server instructions 2026-05-27 17:40:43 +02:00
Nicolas Meienberger
4ea9f34154
chore: portless setup 2026-05-27 17:14:34 +02:00
Nico
e4898b97ea
fix(2fa): add missing 2fa column (#917) 2026-05-22 20:09:30 +02:00
Nico
98338e80c3
Add passkey authentication support (#845)
* feat(auth): add passkey authentication support

* fix: implement AI review feedback

* fix: use non-unique index for passkey_credentialID_idx in migration

* refactor(passkeys): use TanStack mutations for passkey CRUD operations

* chore: restore lockfile from main and add @better-auth/passkey

* chore: fix conflicts

* refactor(passkey-login): simplify passkey autofill event

* refactor(settings-passkeys): ux improvements

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-21 21:18:46 +02:00
Nico
273408cdb8
fix(shutdown): keep the current volume status after shutdown cleanup (#906)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-20 15:04:48 +02:00
Copilot
0589fd63b1
Update Docker Compose docs examples to Zerobyte v0.37 image tag (#905)
* Initial plan

* docs: update docker compose image tags to v0.37

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/067f22a5-8223-4fd3-8e9c-c50f6ab2cc6e

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-05-20 14:38:51 +02:00
Nicolas Meienberger
6bcb0bef71
chore: bump rclone & shoutrrr versions
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-20 13:41:34 +02:00
renovate[bot]
705b8eb748
fix(deps): update bun minor and patch dependencies (#892)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 22:00:51 +02:00
renovate[bot]
05073e667e
chore(deps): update cloudflare/wrangler-action action to v4 (#884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 21:56:11 +02:00
renovate[bot]
970a7fa42f
fix(deps): update dependency content-disposition to v2 (#880)
* fix(deps): update dependency content-disposition to v2

* refactor(content-disposition): use new named export

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-19 21:55:43 +02:00
renovate[bot]
575e01cab9
chore(deps): update dependency bun to v1.3.14 (#885)
* chore(deps): update dependency bun to v1.3.14

* chore: update bun base docker image

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-19 21:28:46 +02:00
renovate[bot]
eb36621b30
chore(deps): update actions/upload-artifact digest to 043fb46 (#777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:48 +02:00
renovate[bot]
da1d4d30b2
chore(deps): update actions/cache digest to 27d5ce7 (#786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:23 +02:00
renovate[bot]
68a7b0beb1
chore(deps): update github/codeql-action digest to 9e0d7b8 (#793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:51:04 +02:00
renovate[bot]
4d5a5d399b
chore(deps): update voidzero-dev/setup-vp digest to ca1c466 (#866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 20:50:45 +02:00
Nico
f82494abab
test: add repository import e2e coverage (#901) 2026-05-19 20:46:08 +02:00
Nico
4dcafa0708
feat(auth): allow skipping forced recovery key download (#900)
* feat(auth): allow skipping forced recovery key download

* refactor: move from session storage to cookie
2026-05-19 20:36:45 +02:00
Nico
c071596151
fix(system): block recovery key download without credential password (#899) 2026-05-19 20:28:09 +02:00
Nico
4b66ad73a7
fix(auth): verify reauth passwords against credential account (#898) 2026-05-19 20:20:55 +02:00
Nico
4a06891db9
ci: remove stale file upload (#897) 2026-05-19 20:14:16 +02:00
Nico
3b9f2086ee
fix(backups): preserve retry settings when toggling schedules (#896) 2026-05-19 20:08:05 +02:00
Nico
94f6d0529f
refactor(mutext): persist repository locks in database (#895)
* refactor(mutext): persist repository locks in database

* fix: clean up promoted repository lock on queued abort

* fix: throttle repository lock cleanup during polling
2026-05-19 18:59:40 +02:00
Nicolas Meienberger
b292f94186
refactor(crypto): reject encrypted values from another secret in sealSecret 2026-05-18 22:00:21 +02:00
Nicolas Meienberger
66ebc249ca
fix: force recovery key redownload after possible truncated download 2026-05-18 21:35:53 +02:00
Nicolas Meienberger
0f5a6823ec
refactor(crypto): reject encrypted values from another secret 2026-05-18 21:14:20 +02:00
Nicolas Meienberger
419204d587
fix(download-password): revoke url after 60 secs 2026-05-18 21:13:07 +02:00
Nico
11dacd7c71
refactor(agent): store SFTP volume keys in tmp (#888)
* refactor(agent): store SFTP volume keys in tmp

* refactor: store temp keys in /run/zerobyte subfolders

* fix(restic): clean temp secrets dir on env setup failure

* fix: one secrets temp dir per env building
2026-05-17 18:19:09 +02:00
Nico
19a0781667
test: backend integration (#889)
* test: backend integration

* docs: mounted shares acls

* feat: smb expose real ACLs when available

* fix: re-init repo on setup

* chore: add missing @hono/standard-validator package

* chore: add happy-dom dev dep
2026-05-17 15:18:56 +02:00
Nico
3142cb026a
chore: move access logs to debug (#891) 2026-05-17 14:26:40 +02:00
Nico
2a1351382f
fix(agent-manager): disable ws server when not needed (#890) 2026-05-17 14:04:49 +02:00
renovate[bot]
0dccd00539
fix(deps): update bun minor and patch dependencies (#867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 13:54:02 +02:00
Nicolas Meienberger
ecbf407058
deps: minimum release age -> 3 days 2026-05-16 11:59:34 +02:00
John Veness
18a91d9b78
README: Fix some punctuation, styling, grammar (#879)
* README: Fix some punctuation, styling, grammar

* Apply suggestion from @JohnVeness

* Update README.md
2026-05-13 17:08:07 +02:00
Nico
a58fe82d48
refactor: move backup path resolution into agent contract (#872) 2026-05-10 21:46:03 +02:00
Nico
aa7da321ba
refactor: dedpulicate volume schemas across packages (#864) 2026-05-09 15:36:25 +02:00
Nico
2ada5acd5a
refactor(agent): harden local agent volume lifecycle (#863)
* refactor(agent): harden local agent volume lifecycle

* chore(test): remove un-used variable

* refactor(agent): create dedicated jobs for recurring tasks

* chore: pr feedbacks

* test: add missing fake agent controller
2026-05-09 12:13:04 +02:00
Nico
2062beac68
refactor(server): route volume operations through agents (#862)
* refactor(server): route volume operations through agents

* chore: pr feedbacks
2026-05-07 20:51:25 +02:00
Nico
df4b668560
feat(agent): add volume operation RPC (#861) 2026-05-07 18:11:57 +02:00
renovate[bot]
5e4742488f
fix(deps): update bun minor and patch dependencies (#865)
* fix(deps): update bun minor and patch dependencies

* fix: ci

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-07 17:11:53 +02:00
renovate[bot]
cda008a53e
chore(deps): update voidzero-dev/setup-vp digest to 4f5aa3e (#818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 17:08:04 +02:00
Nicolas Meienberger
e91df3eab0
chore(renovate): range strategy bump 2026-05-07 09:32:42 +02:00
Nicolas Meienberger
3c7eb65c7c
chore(package-core): remove lock 2026-05-07 09:17:46 +02:00
Nicolas Meienberger
b13b6d606a
chore(renovate): disable minimumRelease age for gh actions 2026-05-07 08:48:01 +02:00
Nicolas Meienberger
66cd9e185b
chore(renovate): rangeStrategy bump 2026-05-07 08:17:45 +02:00
Nicolas Meienberger
1ca1598c58
fix: change dev panel shortcut to D+E+V 2026-05-07 08:17:44 +02:00
renovate[bot]
89ae52d036
chore(deps): update docker/build-push-action digest to bcafcac (#772)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 08:10:59 +02:00
Nico
700fa1cd4a
feat: route backups through volume agents (#860) 2026-05-06 22:13:51 +02:00
Nico
e65a135676
feat(agents): create agent registry and service (#849)
* feat(agents): create agent registry and service

* fix: mark agent offline only if the session was removed properly

* refactor: centralize agent backup lifecycle state

* refactor: simplify session management

* refactor: move effect / async boundary in one place

* chore: regen migration

* refactor: improve error handling

* chore: pr feedback
2026-05-05 19:34:10 +02:00
Nicolas Meienberger
e981211a2d
fix(notifications): validate webhook headers and show delivery health 2026-05-05 10:24:20 +02:00
Nicolas Meienberger
cd69eea27f
fix: avoid unnecessary webhook allowlist checks on notification edits 2026-05-05 10:24:20 +02:00
Nicolas Meienberger
b1ae85e2c1
fix(notifications): preserve existing destinations with target allowlist 2026-05-05 10:24:20 +02:00
Copilot
497a0e8bee
docs: update Docker Compose image tags to v0.36 (#857)
* Initial plan

* docs: update Docker image tags to v0.36

Closes nicotsx/zerobyte#856

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/685fd718-282c-4843-b5bb-082bf8ed0571

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-05-04 22:02:35 +02:00
Nico
147f266929
fix(notifications): enforce allowlist for custom Shoutrrr targets (#853)
Some checks failed
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
* fix(notifications): enforce allowlist for custom Shoutrrr targets

* fix(notifications): enforce allowlist for email notification targets
2026-05-04 17:47:44 +02:00
Nico
0351d5e0b9
refactor(auth): translate better-auth errors allowedHosts (#854)
Closes #852
2026-05-04 17:47:25 +02:00
Nicolas Meienberger
f0ac9fe35c
chore: update new issue link to show templates 2026-05-04 17:09:46 +02:00
Nicolas Meienberger
17d6ec85c3
chore: bump rclone to 1.74.0 2026-05-04 17:05:19 +02:00
Nicolas Meienberger
e7b9b118a8
docs: backup webhooks 2026-05-04 08:12:24 +02:00
Nicolas Meienberger
8772d4796e
feat(backups): configure backup webhook timeout
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-04 07:52:21 +02:00
Nicolas Meienberger
38f5a669ae
fix(core): preserve significant path whitespace 2026-05-04 07:19:57 +02:00
Nicolas Meienberger
7be7c9edae
test(e2e): fix waiting on transitive state
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-05-03 20:51:43 +02:00
Nico
f83b765d04
fix(notifications): persist delivery health status (#850)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
* fix(notifications): persist delivery health status

* fix: pr feedback double update
2026-05-02 11:51:16 +02:00
Antoine Jeanselme
3d5a0a9b75
feat: add repositories & volumes column sorting (#808)
Some checks failed
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
* Add repository column sorting

* Make status title correctly centered o nsmaller screens

* Add volumes column sorting

* refactor: use tanstack table for filtering and sorting

* feat: make notifications sortable

* chore: pr feedbacks

---------

Co-authored-by: Antoine Jeanselme <67123340+ajeanselme@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-02 10:21:42 +02:00
Nicolas Meienberger
d19d827496
fix(backups): default ignore-inode for fuse volumes 2026-05-02 08:54:14 +02:00
Nicolas Meienberger
35dd49d7ac
docs: add sections about new WEBHOOK_ALLOWED_ORIGINS env var 2026-05-01 18:26:13 +02:00
Nicolas Meienberger
94d7fdc9a8
fix(notifications): validate notification webhook origins 2026-05-01 18:22:53 +02:00
Nicolas Meienberger
8fd54383a8
docs: add reference CLI usage 2026-05-01 18:07:52 +02:00
Nico
220e2d2801
feat: make webhook calls trusted only (#848)
* feat: make webhook calls trusted only

* fix: pr feedbacks
2026-04-30 22:14:22 +02:00
Nico
1cf96c5292
refactor(agents): split local agent bootstrap from controller startup (#847) 2026-04-30 21:39:55 +02:00
Nico
11e9fbcc44
feat: pre/post backup webhooks (#835)
* feat: pre/post backup webhooks

* fix(hooks): run post when cancelled

* refactor(webhooks): headers as array

* refactor: pr feedback

* refactor: simplify hooks ceremonies

* chore: pr feedbacks

* chore: re-gen migration
2026-04-29 23:48:58 +02:00
renovate[bot]
6728271a98
fix(deps): update bun minor and patch dependencies (#842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-29 21:12:26 +02:00
Copilot
82e027d872
docs: bump Docker image tags to v0.35 (#839)
* Initial plan

* docs: update Docker image tags to v0.35

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/9862acc4-1f34-4972-b08a-f334ba1de312

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-04-25 14:58:18 +02:00
Nicolas Meienberger
f3eb072182
ci: run update docs workflow only if minor is released 2026-04-25 14:53:54 +02:00
Nicolas Meienberger
eafcf2dd1f
ci: include issue to close to copilot instructions 2026-04-25 14:51:58 +02:00
Nicolas Meienberger
c216fb1430
ci: auto update docs and readme version 2026-04-25 14:44:48 +02:00
Nicolas Meienberger
d567dd9867
docs(readme): update version 2026-04-25 14:38:07 +02:00
Nico
2000ebd254
chore(Dockerfile): pin base image digest (#834) 2026-04-25 09:44:23 +02:00
Nico
a5d47eac5a
fix(system): ensure version check is not cached by browser (#833)
#798
2026-04-25 09:41:29 +02:00
Nico
e506047415
test: add property-based tests with fast-check (#832) 2026-04-25 09:35:28 +02:00
Nicolas Meienberger
7b3b7583c0
chore: improve sanitize function to catch escaped characters 2026-04-25 09:30:16 +02:00
Nico
f078e006c1
fix(notifications): encode SMTP credentials in Shoutrrr URLs (#831)
Closes #829
2026-04-25 09:24:05 +02:00
Nicolas Meienberger
3adc722cca
docs: update SECURITY.md with trust model baseline 2026-04-25 09:03:42 +02:00
Nicolas Meienberger
c64862f604
refactor: extract shared test fixtures and secret helpers 2026-04-22 22:33:18 +02:00
Nico
26203cca59
chore: remove unused deps (#828) 2026-04-22 22:17:51 +02:00
Nico
6ae5a33acd
chore: remove dead files (#827) 2026-04-22 22:13:24 +02:00
Nico
4631655c3e
docs: add security policy (#826) 2026-04-22 22:10:24 +02:00
Nico
10eb9a84fb
ci: restrict workflow GITHUB_TOKEN permissions (#825) 2026-04-22 22:07:24 +02:00
Nico
e2c9ef0518
chore: fixed unused types (#824) 2026-04-22 22:04:46 +02:00
renovate[bot]
04973849a0
chore(deps): update github/codeql-action action to v4 (#822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 21:15:03 +02:00
Nico
a0c34ee48d
chore: cleanup un-used exports (#823) 2026-04-22 21:14:37 +02:00
Nico
3f92dace6c
ci: add scorecard analysis (#820) 2026-04-21 21:39:18 +02:00
Nicolas Meienberger
16085a5fed
chore: bump Bun to 1.3.13 2026-04-21 19:10:48 +02:00
renovate[bot]
d2a0f75e2e
chore(deps): update dependency bun to v1.3.13 (#816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:09:20 +02:00
Nicolas Meienberger
c22af17fa6
fix(volumes): avoid ENOENT during test connection cleanup
Some checks failed
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-04-20 23:06:26 +02:00
Nicolas Meienberger
f967450eea
fix(volumes): unmount existing mounts before remounting
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-04-20 22:34:58 +02:00
Nicolas Meienberger
694f1c212f
fix(volumes): isolate test-connection mounts in temp directories 2026-04-20 22:28:42 +02:00
Nicolas Meienberger
8ece1ef187
test: fix e2e assertions in sync snapshots
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-04-20 22:00:38 +02:00
Nicolas Meienberger
c95cc305f8
chore: bump rclone to 1.73.5 2026-04-20 21:26:19 +02:00
Nico
63b08a4b71
feat(backups): ensure volume readiness before backup (#815)
* feat(backups): ensure volume readiness before backup

Backup preflight checks were relying on a db cached status. Now a real
volume healthcheck is performed before backing up. Closes #811 #695

* chore: fix ci
2026-04-20 21:18:29 +02:00
Nicolas Meienberger
96609fc328
docs: update configuration to mention APP_SECRET_FILE 2026-04-20 19:47:14 +02:00
Nicolas Meienberger
927097a902
chore(packages): change stability days to 1 day 2026-04-20 19:46:54 +02:00
Nicolas Meienberger
cbc874df0d
docs: improve page metadata 2026-04-18 11:30:33 +02:00
Nicolas Meienberger
e19776a2b8
docs: add sitemap.xml 2026-04-18 11:09:56 +02:00
Nicolas Meienberger
c5e35cf8b1
docs: refactor notifications to use tabs 2026-04-18 10:55:49 +02:00
Nicolas Meienberger
ff9decb54b
docs: restoring guide 2026-04-18 10:19:38 +02:00
Nico
0224afae4a
docs: improve provisioning section with more details (#810)
Closes #805
2026-04-18 09:20:20 +02:00
renovate[bot]
72c2f89cc8
chore(deps): update dependency typescript to v6 (#800)
* chore(deps): update dependency typescript to v6

* chore(docs): add tsc script

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:53:50 +02:00
renovate[bot]
6f0617492b
fix(deps): update bun minor and patch dependencies (#795)
* fix(deps): update bun minor and patch dependencies

* chore: update api-client

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:46:05 +02:00
Nicolas Meienberger
dd912b8246
ci: fix docker build 2026-04-16 21:38:42 +02:00
renovate[bot]
d56ee35b06
chore(deps): update dependency @types/node to v25 (#796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 21:38:02 +02:00
renovate[bot]
2619da9c5f
chore(deps): pin dependencies (#794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 21:29:59 +02:00
Eric Hess
d2f65716fe
feat(mirrors): add selective snapshot sync to mirror repositories (#755)
* feat(mirrors): add selective snapshot sync to mirror repositories

Allow users to sync missing snapshots from the source repository to a
mirror. A new sync button per mirror opens a dialog showing which
snapshots are missing, with checkboxes to select which ones to copy.

- Add GET /:shortId/mirrors/:mirrorShortId/status endpoint to compare
  snapshots between source and mirror repositories
- Add POST /:shortId/mirrors/:mirrorShortId/sync endpoint accepting
  selected snapshotIds in the request body
- Extend restic copy command to accept multiple snapshotIds
- Add sync preview dialog with snapshot selection to the frontend

* refactor: stylistic changes

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:28:48 +02:00
renovate[bot]
497fa474a7
fix(deps): update dependency lucide-react to v1 (#804)
* fix(deps): update dependency lucide-react to v1

* fix(deps): replace lucide brand icons with phosphor ones

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-04-16 21:27:19 +02:00
renovate[bot]
28b89036f1
chore(deps): update dependency jsdom to v29 (#799)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 21:15:35 +02:00
renovate[bot]
308d9916ba
chore(deps): update dependency vitest to v4 (#803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 21:09:53 +02:00
Nicolas Meienberger
95006a7471
chore(core package): re-organize files 2026-04-16 19:07:11 +02:00
Nico
5b728b8144
chore: move docs to monorepo apps (#801)
* chore: move docs to monorepo apps

* chore(docs): bump vite
2026-04-16 19:03:00 +02:00
renovate[bot]
1e98013e45
chore(deps): update dependency @vitejs/plugin-react to v6 (#797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 18:37:44 +02:00
renovate[bot]
e059c35fbb
chore(deps): update softprops/action-gh-release action to v3 (#790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 18:35:08 +02:00
Nico
d10a3d2d65
docs: init project (#792)
* docs: init project

docs: design

content

header sticky

docs: content

docs: update starting guide

docs: corrections

docs: oidc, sso & more

feat: landing page

style: card design

style: colors

style: zerobyte logo

style: corner content

style: docs cards

ci(docs): auto deploy to cloudflare

docs: 3-2-1 strategy

* fix: anchor links

* style: refactor landing hero

* feat: og

* chore: fix ci

* ci: build docs before publishing
2026-04-15 23:13:10 +02:00
Nico
33601dde24
feat(agent): add standalone agent runtime (#776)
* feat(agent): add standalone agent runtime

* fix(agent): add Bun and DOM types to agent tsconfig

* refactor: wrap backup error in a tagged effect error

* feat(controller): add agent manager and session handling

* feat(backups): execute backups through the agent

* fix(agent): harden disconnect and send-failure handling

* fix: rebase conflicts

* test: simplify mocks

* refactor: split agent runtime state

* fix(backup): keep old path when agent is disabled

* fix: pr feedbacks
2026-04-13 23:29:10 +02:00
renovate[bot]
7fb5e6d65d
chore(deps): update dependency @effect/language-service to ^0.85.0 (#781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 20:56:18 +02:00
Nicolas Meienberger
b25ccdc6b2
chore: bump shoutrr and rclone versions 2026-04-12 13:41:05 +02:00
Nicolas Meienberger
5497cadb56
chore: bump Bun to 1.3.12 2026-04-12 13:35:52 +02:00
Nicolas Meienberger
2ecd4b8eb6
chore: bump drizzle-orm 2026-04-12 13:34:25 +02:00
Nicolas Meienberger
76ac78c40c
chore: fix ts issues in test files
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-04-12 11:39:06 +02:00
renovate[bot]
97b4ce42c4
chore(deps): update bun minor and patch dependencies (#764)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 11:30:23 +02:00
Nicolas Meienberger
832425025f
refactor(backup): move retry delay minutes conversion at the contract level 2026-04-12 11:28:39 +02:00
Nicolas Meienberger
7ea9899385
Merge branch 'Der-Penz-retry-backup' 2026-04-12 10:50:02 +02:00
Nicolas Meienberger
ed046f284d
fix(backup): reset failure count on cancel 2026-04-12 10:39:14 +02:00
Nicolas Meienberger
cb22e34215
refactor(backup): default to 2 retries & 15 minutes delay 2026-04-12 10:16:26 +02:00
DerPenz
8765c5e705 feature(backup): do not trigger retry on manual backup 2026-04-12 09:49:35 +02:00
DerPenz
11bb95db92 fix(backup): retry wont be scheduled if it is after the next scheduled backup 2026-04-12 09:49:35 +02:00
DerPenz
5d8c2925d8 fix(backup): resetting failureRetryCount if backup is not retried 2026-04-12 09:49:35 +02:00
DerPenz
61396625f1 fix(backup): added limits in zod schema 2026-04-12 09:49:35 +02:00
DerPenz
b2728cb09a fix(backup): removed useless null checks 2026-04-12 09:49:35 +02:00
DerPenz
0476dcebb8 fix(backup): updating new settings correctly in the db 2026-04-12 09:49:35 +02:00
DerPenz
b1cd1a4220 fix(backup): added placeholder in settings 2026-04-12 09:49:35 +02:00
DerPenz
79b7733e1e fix(backup): updated types gen 2026-04-12 09:49:35 +02:00
DerPenz
7e9b1fafa3 feat(backup): added retry policy into job settings 2026-04-12 09:49:35 +02:00
DerPenz
feb69ef7a7 feat(backup): retry backup on failure 2026-04-12 09:49:35 +02:00
Nico
4520335ebc
fix(rclone): pass explicitly the RCLONE_CONF env var (#779) 2026-04-12 09:25:57 +02:00
Nicolas Meienberger
c25eacad05
ci: disable vite hooks in docker build 2026-04-11 19:14:18 +02:00
Nico
73ae5c41b5
style: fix misalignment in mirrors ui (#778) 2026-04-11 08:57:46 +02:00
renovate[bot]
1f4f94b3a5
chore(deps): pin voidzero-dev/setup-vp action to 8ecb391 (#775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 22:36:47 +02:00
Nicolas Meienberger
d213d3546a
ci: install vp globally 2026-04-10 22:33:26 +02:00
Nico
28ba8657f9
feat(runtime): start and ship the local agent (#767)
* feat(runtime): start and ship the local agent

* refactor: gate local agent behind feature flag

* chore: skip agent manager if flag is false

* fix: hot reload agents

* test: fix config tests
2026-04-10 00:00:30 +02:00
Nico
863fbfc5cc
fix(snapshots): use streamed response to avoid 1 MiB buffer limit on large repos (#771) 2026-04-09 23:47:36 +02:00
Nico
70c7de1efc
feat(config): add support for APP_SECRET as a file (#769)
* feat(config): add support for APP_SECRET as a file

* fix(tsc): ensure appSecret is set in type system

* fix: pr feedback
2026-04-09 23:13:07 +02:00
Nico
595a29056d
fix(slack): channel is not supported in combination with webhooks (#770)
#766
2026-04-09 22:24:24 +02:00
Nico
4872029b87
fix(smb-backend): add explicit utf8 charset encoding (#768) 2026-04-09 21:39:19 +02:00
Nicolas Meienberger
979e494e75
docs: update readme 2026-04-09 21:18:25 +02:00
Nico
5bcc3acc56
feat(controller): add agent manager and session handling (#763) 2026-04-09 20:00:54 +02:00
Nico
8a4a06f888
chore: migrate toolchain to vite-plus (#762)
* chore: migrate toolchain to vite-plus

* chore: run lint with bunx

* chore: add vp config postinstall
2026-04-09 17:57:19 +02:00
Nicolas Meienberger
f9027033e5 ci: add environment to release job 2026-04-08 20:59:55 +02:00
renovate[bot]
8c14a2742c
chore(deps): update bun minor and patch dependencies (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 20:47:56 +02:00
Nico
c371676ad0
feat(agent): add standalone agent runtime (#761)
* feat(agent): add standalone agent runtime

* fix(backups): bridge local executor to Effect restic API

* fix(agent): add Bun and DOM types to agent tsconfig

* refactor: wrap backup error in a tagged effect error

* fix: pr feedbacks
2026-04-08 20:47:15 +02:00
Nico
3169627b79
feat(contracts): add shared agent/controller protocol (#759)
* feat(contracts): add shared agent/controller protocol

* chore: pr feedback
2026-04-08 08:17:15 +02:00
506 changed files with 56942 additions and 6768 deletions

View file

@ -3,6 +3,7 @@
!bun.lock !bun.lock
!package.json !package.json
!.gitignore !.gitignore
!bunfig.toml
!**/package.json !**/package.json
!**/bun.lock !**/bun.lock
@ -11,6 +12,7 @@
!**/components.json !**/components.json
!app/** !app/**
!apps/agent/**
!packages/** !packages/**
!public/** !public/**
@ -22,3 +24,4 @@
node_modules/** node_modules/**
dist/** dist/**
.output/** .output/**
app/test/integration/artifacts/**

View file

@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes ZEROBYTE_VOLUMES_DIR=./data/volumes
APP_SECRET=<openssl rand -hex 32> APP_SECRET=<openssl rand -hex 32>
BASE_URL=http://localhost:300 BASE_URL=http://*.localhost
TRUST_PROXY=false TRUST_PROXY=false
PORT=3000 PORT=3000

View file

@ -1,3 +1,4 @@
ZEROBYTE_DATABASE_URL=:memory: ZEROBYTE_DATABASE_URL=:memory:
APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69 APP_SECRET=8b9acd4456dd5db0a4a3c4f4e1240b2c3ae08bb59690167197425e4a25dd9a69
BASE_URL=http://localhost:4096 BASE_URL=http://localhost:4096
ENABLE_LOCAL_AGENT=true

13
.fallowrc.json Normal file
View file

@ -0,0 +1,13 @@
{
"entry": [
"app/client.tsx",
"app/router.tsx",
"app/server.ts",
"app/server/plugins/bootstrap.ts",
"openapi-ts.config.ts",
"scripts/create-test-files.ts",
"scripts/patch-api-client.ts"
],
"ignorePatterns": ["node_modules/**", ".output/**", "**/api-client/**", "**/routeTree.gen.ts", "apps/docs/**"],
"ignoreDependencies": ["@tailwindcss/typography", "babel-plugin-react-compiler"]
}

View file

@ -1,6 +1,11 @@
name: Install dependencies name: Install dependencies
description: Install dependencies description: Install dependencies
inputs:
workingDirectory:
description: "The working directory to install dependencies in"
required: false
default: "."
runs: runs:
using: "composite" using: "composite"
@ -12,8 +17,15 @@ runs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
name: Install Bun name: Install Bun
with: with:
bun-version: "1.3.11" bun-version: "1.3.14"
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
with:
node-version: '24'
cache: true
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: sfw bun install --frozen-lockfile run: |
cd ${{ inputs.workingDirectory }}
sfw vp install --frozen-lockfile

View file

@ -3,7 +3,13 @@
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"], "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"enabledManagers": ["bun", "github-actions"], "enabledManagers": ["bun", "github-actions"],
"minimumReleaseAge": "3 days", "minimumReleaseAge": "3 days",
"rangeStrategy": "bump",
"packageRules": [ "packageRules": [
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["digest", "pinDigest"],
"minimumReleaseAge": null
},
{ {
"matchManagers": ["bun"], "matchManagers": ["bun"],
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],

View file

@ -27,11 +27,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
uses: "./.github/actions/install-dependencies" uses: "./.github/actions/install-dependencies"
- uses: oxc-project/oxlint-action@0cc5d219e22e8cdfd8f67be492213ad3860e25e4 # latest - name: Run lint
with: shell: bash
config: .oxlintrc.json run: vp lint --type-aware --deny-warnings
deny-warnings: true
version: 1.56.0
typecheck: typecheck:
timeout-minutes: 10 timeout-minutes: 10

41
.github/workflows/docs-deploy.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Docs deploy
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "apps/docs/**"
jobs:
deploy:
name: Deploy docs
if: github.event.repository.default_branch == github.ref_name
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Build docs
working-directory: apps/docs
run: bun run build
- name: Build and deploy docs
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/docs

View file

@ -26,7 +26,7 @@ jobs:
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
id: playwright-cache id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
@ -96,7 +96,7 @@ jobs:
run: | run: |
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always() if: always()
with: with:
name: playwright-report name: playwright-report

35
.github/workflows/integration.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Integration Tests
permissions:
contents: read
on:
workflow_dispatch:
workflow_call:
jobs:
integration:
name: Integration
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run integration tests
env:
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "true"
run: bun run test:integration
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
name: integration-artifacts
path: |
app/test/integration/artifacts/compose.log
app/test/integration/artifacts/docker-output.log
app/test/integration/artifacts/runs/**
retention-days: 5

View file

@ -6,31 +6,34 @@ on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
packages: write contents: read
jobs: jobs:
build-and-push: build-and-push:
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with: with:
driver: cloud driver: cloud
endpoint: "meienberger/runtipi-builder" endpoint: "meienberger/runtipi-builder"
install: true install: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -38,14 +41,14 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: | tags: |
type=raw,value=nightly type=raw,value=nightly
- name: Push images to GitHub Container Registry - name: Push images to GitHub Container Registry
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with: with:
context: . context: .
target: production target: production

View file

@ -8,16 +8,16 @@ on:
- "v*.*.*-alpha.*" - "v*.*.*-alpha.*"
permissions: permissions:
contents: write contents: read
packages: write
security-events: write
jobs: jobs:
determine-release-type: determine-release-type:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: {}
outputs: outputs:
release_type: ${{ steps.set-type.outputs.release_type }} release_type: ${{ steps.set-type.outputs.release_type }}
tagname: ${{ github.ref_name }} tagname: ${{ github.ref_name }}
is_minor_release: ${{ steps.set-type.outputs.is_minor_release }}
steps: steps:
- name: Set release type - name: Set release type
id: set-type id: set-type
@ -31,16 +31,30 @@ jobs:
echo "release_type=release" >> $GITHUB_OUTPUT echo "release_type=release" >> $GITHUB_OUTPUT
fi fi
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then
echo "is_minor_release=true" >> $GITHUB_OUTPUT
else
echo "is_minor_release=false" >> $GITHUB_OUTPUT
fi
checks: checks:
uses: ./.github/workflows/checks.yml uses: ./.github/workflows/checks.yml
e2e-tests: e2e-tests:
uses: ./.github/workflows/e2e.yml uses: ./.github/workflows/e2e.yml
integration-tests:
uses: ./.github/workflows/integration.yml
build-images: build-images:
environment: release
timeout-minutes: 15 timeout-minutes: 15
needs: [determine-release-type, checks, e2e-tests] needs: [determine-release-type, checks, e2e-tests, integration-tests]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@ -49,56 +63,28 @@ jobs:
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with: with:
driver: cloud driver: cloud
endpoint: "meienberger/runtipi-builder" endpoint: "meienberger/runtipi-builder"
install: true install: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
target: production
platforms: linux/amd64
push: false
load: true
tags: local/zerobyte:ci
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
- name: Scan new image for vulnerabilities
if: needs.determine-release-type.outputs.release_type == 'release'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
id: scan
with:
image: local/zerobyte:ci
fail-build: false
only-fixed: true
severity-cutoff: critical
- name: upload Anchore scan report
if: needs.determine-release-type.outputs.release_type == 'release'
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: | tags: |
@ -110,7 +96,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry - name: Push images to GitHub Container Registry
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with: with:
context: . context: .
target: production target: production
@ -125,12 +111,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-images, determine-release-type] needs: [build-images, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release' if: needs.determine-release-type.outputs.release_type == 'release'
permissions:
contents: write
outputs: outputs:
id: ${{ steps.create_release.outputs.id }} id: ${{ steps.create_release.outputs.id }}
steps: steps:
- name: Create GitHub release - name: Create GitHub release
id: create_release id: create_release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
@ -140,4 +128,13 @@ jobs:
name: ${{ needs.determine-release-type.outputs.tagname }} name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false draft: false
prerelease: true prerelease: true
files: cli/runtipi-cli-*
request-docs-version-update:
uses: ./.github/workflows/request-docs-version-update.yml
needs: [publish-release, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release' && needs.determine-release-type.outputs.is_minor_release == 'true'
with:
release_tag: ${{ needs.determine-release-type.outputs.tagname }}
base_branch: ${{ github.event.repository.default_branch }}
secrets:
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}

View file

@ -0,0 +1,122 @@
name: Request docs version update
on:
workflow_dispatch:
inputs:
release_tag:
description: Minor release tag, for example v0.35.0
required: true
type: string
base_branch:
description: Branch Copilot should branch from
required: false
default: main
type: string
workflow_call:
inputs:
release_tag:
required: true
type: string
base_branch:
required: false
default: main
type: string
secrets:
COPILOT_ASSIGNMENT_TOKEN:
required: true
permissions:
contents: read
jobs:
request-docs-version-update:
runs-on: ubuntu-latest
steps:
- name: Create Copilot issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}
script: |
if (!process.env.COPILOT_ASSIGNMENT_TOKEN) {
core.setFailed("COPILOT_ASSIGNMENT_TOKEN is required to assign issues to GitHub Copilot.");
return;
}
const releaseTag = process.env.RELEASE_TAG;
const baseBranch = process.env.BASE_BRANCH;
const minorRelease = releaseTag.match(/^(v\d+\.\d+)\.0$/);
if (!minorRelease) {
core.setFailed(`Docs version updates only run for minor release tags like v0.35.0. Received: ${releaseTag}`);
return;
}
const docsVersion = minorRelease[1];
const { owner, repo } = context.repo;
const targetRepo = `${owner}/${repo}`;
const releaseUrl = `https://github.com/${targetRepo}/releases/tag/${releaseTag}`;
const title = `Update Docker Compose examples to ${docsVersion}`;
const bodyLines = [
`A new Zerobyte release was published: ${releaseTag}`,
"",
`Release: ${releaseUrl}`,
"",
"Please create a pull request that updates Docker Compose documentation examples to use:",
"",
"```yaml",
`image: ghcr.io/nicotsx/zerobyte:${docsVersion}`,
"```",
"",
"Scope:",
"",
"- Update Docker image tags in `README.md`.",
"- Update Docker image tags in `apps/docs/`.",
"- Only update Zerobyte image versions in Docker Compose examples.",
"- Do not change unrelated prose, formatting, or code.",
"",
"Verification:",
"",
"- Search for stale `ghcr.io/nicotsx/zerobyte:v...` references in `README.md` and `apps/docs/`.",
"- Run any formatting or documentation checks that are appropriate for the touched files.",
];
const issue = await github.rest.issues.create({
owner,
repo,
title,
body: bodyLines.join("\n"),
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.data.number,
body: [
...bodyLines,
"",
"Pull request requirement:",
"",
`- Include \`Closes #${issue.data.number}\` in the pull request body.`,
].join("\n"),
});
await github.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", {
owner,
repo,
issue_number: issue.data.number,
assignees: ["copilot-swe-agent[bot]"],
agent_assignment: {
target_repo: targetRepo,
base_branch: baseBranch,
custom_instructions: "Create a focused documentation-only PR for the release version update.",
custom_agent: "",
model: "",
},
headers: {
accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
env:
RELEASE_TAG: ${{ inputs.release_tag }}
BASE_BRANCH: ${{ inputs.base_branch }}
COPILOT_ASSIGNMENT_TOKEN: ${{ secrets.COPILOT_ASSIGNMENT_TOKEN }}

41
.github/workflows/scorecard.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Scorecard analysis
on:
branch_protection_rule:
push:
branches: [ "main" ]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
security-events: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
sarif_file: results.sarif

6
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt smb-password.txt
cache.db cache.db
data/ /data
.env* .env*
!.env.example !.env.example
@ -34,8 +34,11 @@ node_modules/
playwright/.auth playwright/.auth
playwright/temp playwright/temp
playwright/data
.idea/ .idea/
.deepsec/
.codex/
# OpenAPI error logs # OpenAPI error logs
openapi-ts-error-*.log openapi-ts-error-*.log
@ -46,3 +49,4 @@ qa-output
.worktrees/ .worktrees/
.turbo .turbo
.superset .superset
.fallow

View file

@ -1,7 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"useTabs": true,
"endOfLine": "lf",
"ignorePatterns": ["*.gen.ts"]
}

View file

@ -1,162 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
"categories": {},
"rules": {
"constructor-super": "warn",
"for-direction": "warn",
"no-async-promise-executor": "warn",
"no-caller": "warn",
"no-class-assign": "warn",
"no-compare-neg-zero": "warn",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "warn",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "warn",
"no-unused-expressions": "warn",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"argsIgnorePattern": "^_"
}
],
"no-useless-backreference": "warn",
"no-useless-catch": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error",
"import/no-unused-modules": ["error", { "unusedExports": true }],
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": [],
"version": null
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
},
"vitest": {
"typecheck": false
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": ["**/api-client/**"],
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"typescript/await-thenable": "off"
}
}
]
}

1
.vite-hooks/pre-commit Executable file
View file

@ -0,0 +1 @@
vp staged

View file

@ -2,7 +2,7 @@
- Never create migration files manually. Always use the provided command to generate migrations - Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration - If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password` - The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password`
- The repo is https://github.com/nicotsx/zerobyte - The repo is https://github.com/nicotsx/zerobyte
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D` - If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
@ -10,6 +10,21 @@
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
### Development Server
```bash
# Start the dev server through Portless
bun run dev
# Get the current app URL for this worktree
portless get zerobyte
# Inspect active Portless routes if needed
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
### Type Checking ### Type Checking
```bash ```bash
@ -55,7 +70,7 @@ bun run gen:api-client
```bash ```bash
# Format # Format
bunx oxfmt format --write <path> vp fmt <path> --write
# Lint # Lint
bun run lint bun run lint

View file

@ -1,10 +1,8 @@
ARG BUN_VERSION="1.3.11" FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
FROM oven/bun:${BUN_VERSION}-alpine AS base
ARG RESTIC_VERSION="0.18.1" ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.73.3" ARG RCLONE_VERSION="1.74.2"
ARG SHOUTRRR_VERSION="0.14.2" ARG SHOUTRRR_VERSION="0.15.1"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -12,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
RUN apk update --no-cache && \ RUN apk update --no-cache && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]
@ -46,6 +44,15 @@ RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------ # ------------------------------
# DEVELOPMENT # DEVELOPMENT
# ------------------------------ # ------------------------------
@ -64,8 +71,11 @@ COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN bun install --frozen-lockfile --ignore-scripts RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile --ignore-scripts
COPY . . COPY . .
@ -86,11 +96,16 @@ WORKDIR /app
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json COPY ./packages/core/package.json ./packages/core/package.json
RUN bun install --frozen-lockfile COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
COPY . . COPY . .
RUN bun run build RUN bun run build
RUN bun build apps/agent/src/index.ts --outfile .output/agent/index.mjs --target bun
FROM base AS production FROM base AS production

143
README.md
View file

@ -15,23 +15,33 @@
</figure> </figure>
</div> </div>
#### Join the community
[![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH)
> [!WARNING] > [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests > Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
<p align="center"> <p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a> <a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p> </p>
## Intro ## Introduction
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage. Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
## Documentation
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
### Features ### Features
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic - **Automated backups** with encryption, compression, and retention policies, powered by Restic
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies - **Flexible scheduling** for automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected - **End-to-end encryption** will ensure your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories - **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
## Installation ## Installation
@ -40,7 +50,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.32 image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -59,10 +69,10 @@ services:
``` ```
> [!WARNING] > [!WARNING]
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication. > It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
> [!WARNING] > [!WARNING]
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation. > Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
> [!NOTE] > [!NOTE]
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead: > **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
@ -87,23 +97,46 @@ Once the container is running, you can access the web interface at `http://<your
Zerobyte can be customized using environment variables. Below are the available options: Zerobyte can be customized using environment variables. Below are the available options:
### Environment Variables ### Environment variables
| Variable | Description | Default | | Variable | Description | Default |
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- | | :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) | | `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) | | `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` | | `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` | | `PORT` | The port the web interface and API will listen on. | `4096` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` | | `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` | | `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) | | `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` | | `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` | | `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `RCLONE_CONFIG_DIR` | Path to the rclone config directory inside the container. Change this if running as a non-root user. | `/root/.config/rclone` | | `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) | | `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
### Provisioned Resources ### Webhook and notification network policy
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
The allowlist matches exact origins only: scheme, host, and port must match. Paths are ignored, so `https://hooks.example.com/backups` allows any path on `https://hooks.example.com`, but it does not allow `http://hooks.example.com`, `https://hooks.example.com:8443`, or `https://other.example.com`.
This policy applies to:
- backup pre/post webhook URLs
- Generic HTTP notification URLs
- Gotify server URLs
- self-hosted ntfy server URLs
- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets
The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`.
Backup webhooks do not follow redirects. Add the final destination origin to `WEBHOOK_ALLOWED_ORIGINS` and configure that final URL directly.
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
### Provisioned resources
Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI. Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI.
@ -112,16 +145,18 @@ Provisioned resources:
- appear in the normal repositories and volumes screens - appear in the normal repositories and volumes screens
- can resolve credential fields from environment variables or `/run/secrets/*` during startup sync - can resolve credential fields from environment variables or `/run/secrets/*` during startup sync
The complete provisioning documentation is available at [zerobyte.app/docs/guides/provisioning](https://zerobyte.app/docs/guides/provisioning).
See `examples/provisioned-resources/README.md` for a full example. See `examples/provisioned-resources/README.md` for a full example.
### Simplified setup (No remote mounts) ### Simplified setup (no remote mounts)
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`: If you only need to back up locally-mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
```yaml ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.32 image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
ports: ports:
@ -139,8 +174,8 @@ services:
**Trade-offs:** **Trade-offs:**
- ✅ Improved security by reducing container capabilities - ✅ Improved security by reducing container capabilities
- ✅ Support for local directories - ✅ Support for local directories as backup sources
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) - ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte - ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
@ -155,12 +190,12 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section: If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.32 image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -188,17 +223,17 @@ docker compose up -d
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path. Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true) ![Add a new volume UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
## Creating a repository ## Creating a repository
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories: A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path - **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc. - **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service - **Google Cloud Storage**: Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage - **Azure Blob Storage**: Microsoft Azure storage
- **rclone remotes** - 40+ cloud storage providers via rclone (see below) - **rclone remotes**: 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features. Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
@ -235,7 +270,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.32 image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@ -253,7 +288,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
+ - ~/.config/rclone:/root/.config/rclone:ro + - ~/.config/rclone:/root/.config/rclone:ro
``` ```
> **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONF_DIR`: > **Note for non-root users:** If your container runs as a different user (e.g., TrueNAS apps), mount your config to the appropriate location and set `RCLONE_CONFIG_DIR`:
> >
> ```yaml > ```yaml
> environment: > environment:
@ -289,24 +324,30 @@ When creating a backup job, you can specify the following settings:
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule. After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface. You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true) ![Backups UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
## Restoring data ## Restoring data
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location. Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true) ![Restore UI](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Authentication ## Authentication
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario: Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
### Cookie Security ### Users and roles
- **IP Address / HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL. Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
- **Domain / HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
### Reverse Proxy Setup Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
### Cookie security
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
### Reverse proxy setup
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.): If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
@ -314,7 +355,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
2. The app will automatically enable secure cookies based on the `https://` prefix 2. The app will automatically enable secure cookies based on the `https://` prefix
3. Ensure your proxy passes the `X-Forwarded-Proto` header 3. Ensure your proxy passes the `X-Forwarded-Proto` header
### Important Notes ### Important notes
- The `BASE_URL` must start with `https://` for secure cookies to be enabled - The `BASE_URL` must start with `https://` for secure cookies to be enabled
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically - Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
@ -325,7 +366,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file. For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
## Third-Party Software ## Third-party software
This project includes the following third-party software components: This project includes the following third-party software components:
@ -346,10 +387,10 @@ Contributions by anyone are welcome! If you find a bug or have a feature request
## Development (no Docker) ## Development (no Docker)
You can run Zerobyte locally during development without Docker: After installing Vite+ globally, you can run Zerobyte locally during development without Docker:
```bash ```bash
bun install vp install
bun run dev bun run dev
``` ```
@ -363,10 +404,10 @@ RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes ZEROBYTE_VOLUMES_DIR=./data/volumes
BASE_URL=http://localhost:4096 BASE_URL=https://*.localhost
``` ```
Notes: Notes:
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable. - Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
- To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error. - To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.

75
SECURITY.md Normal file
View file

@ -0,0 +1,75 @@
# Security Policy
Thank you for helping keep Zerobyte and its users secure.
Zerobyte is currently in `0.x`, and releases may include breaking changes between versions. For that reason, security fixes are only guaranteed for the most recent stable release line.
## Supported Versions
| Version | Supported |
| --------------------------------------------- | ------------------ |
| Latest stable `0.x` release | :white_check_mark: |
| Older stable `0.x` releases | :x: |
| Pre-release builds (`-alpha`, `-beta`, `-rc`) | :x: |
| Development snapshots from `main` | :x: |
Security fixes are generally released in the latest stable version only. If you are running an older release, you may be asked to upgrade before a fix is provided.
## Reporting a Vulnerability
Please do **not** report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Use one of these private channels instead:
1. Preferred: GitHub private vulnerability reporting
https://github.com/nicotsx/zerobyte/security/advisories/new
2. Alternative: contact the maintainer directly by email if an address is listed in the repository or GitHub profile
When reporting, include as much of the following as you can:
- affected Zerobyte version
- deployment details, including whether you are using Docker, reverse proxies, or exposed ports
- clear reproduction steps or a proof of concept
- impact assessment and what an attacker could do
- any relevant logs, screenshots, or configuration excerpts with secrets removed
### What to expect
- We aim to acknowledge new reports within 7 days.
- We aim to provide status updates at least every 7 days while the report is being investigated.
- If the report is accepted, we will work on a fix, coordinate disclosure, and publish a security advisory when appropriate.
- If the report is declined, out of scope, or cannot be reproduced, we will explain why when possible.
Please avoid public disclosure until a fix has been released and maintainers have had reasonable time to notify users.
## Trust model baseline
Zerobyte is a self-hosted operator tool. Treat any authenticated user as a trusted machine/operator user with intentional access to:
- Browse/select host directories for volumes
- Configure local, network, and cloud storage backends
- Trigger mounts/unmounts, backups, restores, and Restic maintenance
- Read/write files through intended backup/restore workflows
- Access repository/volume metadata needed to operate backups
Do **not** report these as vulnerabilities by themselves:
- Authenticated host filesystem browsing
- Local directory volume pointing to broad host paths
- Backing up arbitrary readable host paths
- Restoring snapshots to arbitrary writable host paths
- Authenticated Restic/mount/rclone execution through intended UI flows
- Information disclosure to authenticated operators about filesystem paths or backend errors
Only report issues when they violate this trust model, for example:
- Unauthenticated access to operator features
- CSRF/cross-origin abuse causing a trusted operators browser to perform actions
- Shell/command injection beyond intended argument-based execution
- Path traversal that escapes a deliberately configured root/volume/repository boundary
- Secret leakage to logs, unauthenticated users, or non-operator contexts
- Cross-organization data access despite authenticated trust
- Privilege bypass between global admin/org admin/member where the product explicitly distinguishes roles
- Unsafe dev-only features enabled without the documented gate
- Vulnerabilities in parsing untrusted external data from repositories/backends/notifications
- Persistence corruption, data loss, or workflow bypass not intended by operator actions

View file

@ -283,9 +283,8 @@ If you're experiencing rclone issues, verify all of the following:
**Diagnosis:** **Diagnosis:**
```bash ```bash
# Check if config is accessible inside container # Check which config file rclone will use inside the container
docker exec zerobyte ls -la /root/.config/rclone/ docker exec zerobyte sh -lc 'echo HOME=$HOME; rclone config file'
docker exec zerobyte cat /root/.config/rclone/rclone.conf
``` ```
**Solutions:** **Solutions:**

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -1098,6 +1098,41 @@ export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateSc
return mutationOptions; return mutationOptions;
}; };
export const getMirrorSyncStatusQueryKey = (options: Options<GetMirrorSyncStatusData>) => createQueryKey('getMirrorSyncStatus', options);
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatusOptions = (options: Options<GetMirrorSyncStatusData>) => queryOptions<GetMirrorSyncStatusResponse, DefaultError, GetMirrorSyncStatusResponse, ReturnType<typeof getMirrorSyncStatusQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorSyncStatus({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorSyncStatusQueryKey(options)
});
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirrorMutation = (options?: Partial<Options<SyncMirrorData>>): UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> => {
const mutationOptions: UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> = {
mutationFn: async (fnOptions) => {
const { data } = await syncMirror({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options); export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
/** /**

View file

@ -32,7 +32,14 @@ export const createClient = (config: Config = {}): Client => {
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>(); const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async (options: RequestOptions) => { const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const opts = { const opts = {
..._config, ..._config,
...options, ...options,
@ -42,10 +49,7 @@ export const createClient = (config: Config = {}): Client => {
}; };
if (opts.security) { if (opts.security) {
await setAuthParams({ await setAuthParams(opts);
...opts,
security: opts.security,
});
} }
if (opts.requestValidator) { if (opts.requestValidator) {
@ -61,178 +65,162 @@ export const createClient = (config: Config = {}): Client => {
opts.headers.delete('Content-Type'); opts.headers.delete('Content-Type');
} }
const url = buildUrl(opts); const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts, url }; return { opts: resolvedOpts, url };
}; };
const request: Client['request'] = async (options) => { const request: Client['request'] = async (options) => {
// @ts-expect-error const throwOnError = options.throwOnError ?? _config.throwOnError;
const { opts, url } = await beforeRequest(options); const responseStyle = options.responseStyle ?? _config.responseStyle;
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit); let request: Request | undefined;
let response: Response | undefined;
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try { try {
response = await _fetch(request); const { opts, url } = await beforeRequest(options);
} catch (error) { const requestInit: ReqInit = {
// Handle fetch exceptions (AbortError, network errors, etc.) redirect: 'follow',
let finalError = error; ...opts,
body: getValidRequestBody(opts),
};
for (const fn of interceptors.error.fns) { request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) { if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown; request = await fn(request, opts);
} }
} }
finalError = finalError || ({} as unknown); // fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
if (opts.throwOnError) { response = await _fetch(request);
throw finalError;
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
} }
// Return error response const result = {
return opts.responseStyle === 'data' request,
? undefined response,
: { };
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) { if (response.ok) {
if (fn) { const parseAs =
response = await fn(response, request, opts); (opts.parseAs === 'auto'
} ? getParseAs(response.headers.get('Content-Type'))
} : opts.parseAs) ?? 'json';
const result = { if (response.status === 204 || response.headers.get('Content-Length') === '0') {
request, let emptyData: any;
response, switch (parseAs) {
}; case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
if (response.ok) { let data: any;
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) { switch (parseAs) {
case 'arrayBuffer': case 'arrayBuffer':
case 'blob': case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData': case 'formData':
emptyData = new FormData(); case 'text':
data = await response[parseAs]();
break; break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream': case 'stream':
emptyData = response.body; return opts.responseStyle === 'data'
break; ? response.body
case 'json': : {
default: data: response.body,
emptyData = {}; ...result,
break; };
} }
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data' return opts.responseStyle === 'data'
? emptyData ? data
: { : {
data: emptyData, data,
...result, ...result,
}; };
} }
let data: any; const textError = await response.text();
switch (parseAs) { let jsonError: unknown;
case 'arrayBuffer':
case 'blob': try {
case 'formData': jsonError = JSON.parse(textError);
case 'text': } catch {
data = await response[parseAs](); // noop
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
} }
if (parseAs === 'json') { throw jsonError ?? textError;
if (opts.responseValidator) { } catch (error) {
await opts.responseValidator(data); let finalError = error;
}
if (opts.responseTransformer) { for (const fn of interceptors.error.fns) {
data = await opts.responseTransformer(data); if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
} }
} }
return opts.responseStyle === 'data' finalError = finalError || {};
? data
if (throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return responseStyle === 'data'
? undefined
: { : {
data, error: finalError,
...result, request,
response,
}; };
} }
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
}; };
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
@ -243,7 +231,6 @@ export const createClient = (config: Config = {}): Client => {
return createSseClient({ return createSseClient({
...opts, ...opts,
body: opts.body as BodyInit | null | undefined, body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method, method,
onRequest: async (url, init) => { onRequest: async (url, init) => {
let request = new Request(url, init); let request = new Request(url, init);

View file

@ -94,6 +94,7 @@ export interface ResolvedRequestOptions<
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string; serializedBody?: string;
} }
@ -127,8 +128,10 @@ export type RequestResult<
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError; error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
} }
) & { ) & {
request: Request; /** request may be undefined, because error may be from building the request object itself */
response: Response; request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
} }
>; >;
@ -153,7 +156,7 @@ type SseFn = <
ThrowOnError extends boolean = false, ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields', TResponseStyle extends ResponseStyle = 'fields',
>( >(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>; ) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = < type RequestFn = <

View file

@ -119,14 +119,12 @@ const checkForExistence = (
return false; return false;
}; };
export const setAuthParams = async ({ export async function setAuthParams(
security, options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers; headers: Headers;
}) => { },
for (const auth of security) { ): Promise<void> {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) { if (checkForExistence(options, auth.name)) {
continue; continue;
} }
@ -155,7 +153,7 @@ export const setAuthParams = async ({
break; break;
} }
} }
}; }
export const buildUrl: Client['buildUrl'] = (options) => export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({ getUrl({
@ -205,7 +203,7 @@ export const mergeHeaders = (
mergedHeaders.append(key, v as string); mergedHeaders.append(key, v as string);
} }
} else if (value !== undefined) { } else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their // assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json' // content value in OpenAPI specification is 'application/json'
mergedHeaders.set( mergedHeaders.set(
key, key,
@ -219,8 +217,10 @@ export const mergeHeaders = (
type ErrInterceptor<Err, Res, Req, Options> = ( type ErrInterceptor<Err, Res, Req, Options> = (
error: Err, error: Err,
response: Res, /** response may be undefined due to a network error where no response object is produced */
request: Req, response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options, options: Options,
) => Err | Promise<Err>; ) => Err | Promise<Err>;

View file

@ -80,7 +80,7 @@ export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unkn
>; >;
}; };
export const createSseClient = <TData = unknown>({ export function createSseClient<TData = unknown>({
onRequest, onRequest,
onSseError, onSseError,
onSseEvent, onSseEvent,
@ -92,7 +92,7 @@ export const createSseClient = <TData = unknown>({
sseSleepFn, sseSleepFn,
url, url,
...options ...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => { }: ServerSentEventsOptions): ServerSentEventsResult<TData> {
let lastEventId: string | undefined; let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
@ -156,8 +156,7 @@ export const createSseClient = <TData = unknown>({
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += value; buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n'); const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? ''; buffer = chunks.pop() ?? '';
@ -241,4 +240,4 @@ export const createSseClient = <TData = unknown>({
const stream = createStream(); const stream = createStream();
return { stream }; return { stream };
}; }

View file

@ -81,7 +81,7 @@ export interface Config {
requestValidator?: (data: unknown) => Promise<unknown>; requestValidator?: (data: unknown) => Promise<unknown>;
/** /**
* A function transforming response data before it's returned. This is useful * A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects. * for post-processing data, e.g., converting ISO strings into Date objects.
*/ */
responseTransformer?: (data: unknown) => Promise<unknown>; responseTransformer?: (data: unknown) => Promise<unknown>;
/** /**

View file

@ -127,7 +127,7 @@ export function getValidRequestBody(options: {
return hasSerializedBody ? options.serializedBody : null; return hasSerializedBody ? options.serializedBody : null;
} }
// not all clients implement a serializedBody property (i.e. client-axios) // not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null; return options.body !== '' ? options.body : null;
} }

View file

@ -26,6 +26,7 @@ export {
getBackupScheduleForVolume, getBackupScheduleForVolume,
getDevPanel, getDevPanel,
getMirrorCompatibility, getMirrorCompatibility,
getMirrorSyncStatus,
getNotificationDestination, getNotificationDestination,
getOrgMembers, getOrgMembers,
getPublicSsoProviders, getPublicSsoProviders,
@ -62,6 +63,7 @@ export {
setRegistrationStatus, setRegistrationStatus,
startDoctor, startDoctor,
stopBackup, stopBackup,
syncMirror,
tagSnapshots, tagSnapshots,
testConnection, testConnection,
testNotificationDestination, testNotificationDestination,
@ -153,6 +155,9 @@ export type {
GetMirrorCompatibilityData, GetMirrorCompatibilityData,
GetMirrorCompatibilityResponse, GetMirrorCompatibilityResponse,
GetMirrorCompatibilityResponses, GetMirrorCompatibilityResponses,
GetMirrorSyncStatusData,
GetMirrorSyncStatusResponse,
GetMirrorSyncStatusResponses,
GetNotificationDestinationData, GetNotificationDestinationData,
GetNotificationDestinationErrors, GetNotificationDestinationErrors,
GetNotificationDestinationResponse, GetNotificationDestinationResponse,
@ -263,6 +268,10 @@ export type {
StopBackupErrors, StopBackupErrors,
StopBackupResponse, StopBackupResponse,
StopBackupResponses, StopBackupResponses,
SyncMirrorData,
SyncMirrorErrors,
SyncMirrorResponse,
SyncMirrorResponses,
TagSnapshotsData, TagSnapshotsData,
TagSnapshotsResponse, TagSnapshotsResponse,
TagSnapshotsResponses, TagSnapshotsResponses,

View file

@ -3,9 +3,9 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
* You can provide a client instance returned by `createClient()` instead of * You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a * individual options. This might be also useful if you want to implement a
@ -307,7 +307,7 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
/** /**
* Execute a restic command against a repository (dev panel only) * Execute a restic command against a repository (dev panel only)
*/ */
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({ export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError, DevPanelExecResponse>) => (options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
url: '/api/v1/repositories/{shortId}/exec', url: '/api/v1/repositories/{shortId}/exec',
...options, ...options,
headers: { headers: {
@ -409,6 +409,23 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
} }
}); });
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>) => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>) => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/ */

View file

@ -337,14 +337,7 @@ export type ListVolumesResponses = {
200: Array<{ 200: Array<{
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -359,6 +352,7 @@ export type ListVolumesResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -392,7 +386,15 @@ export type ListVolumesResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}>; }>;
}; };
@ -416,6 +418,7 @@ export type CreateVolumeData = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -449,6 +452,7 @@ export type CreateVolumeData = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
}; };
path?: never; path?: never;
@ -463,14 +467,7 @@ export type CreateVolumeResponses = {
201: { 201: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -485,6 +482,7 @@ export type CreateVolumeResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -518,7 +516,15 @@ export type CreateVolumeResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
}; };
@ -541,6 +547,7 @@ export type TestConnectionData = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -574,6 +581,7 @@ export type TestConnectionData = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
}; };
path?: never; path?: never;
@ -637,14 +645,7 @@ export type GetVolumeResponses = {
volume: { volume: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -659,6 +660,7 @@ export type GetVolumeResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -692,13 +694,21 @@ export type GetVolumeResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
statfs: { statfs: {
total: number; total?: number;
used: number; used?: number;
free: number; free?: number;
}; };
}; };
}; };
@ -723,6 +733,7 @@ export type UpdateVolumeData = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -756,6 +767,7 @@ export type UpdateVolumeData = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
}; };
path: { path: {
@ -779,14 +791,7 @@ export type UpdateVolumeResponses = {
200: { 200: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -801,6 +806,7 @@ export type UpdateVolumeResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -834,7 +840,15 @@ export type UpdateVolumeResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
}; };
@ -855,8 +869,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully * Volume mounted successfully
*/ */
200: { 200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error'; status: 'mounted' | 'unmounted' | 'error';
error?: string;
}; };
}; };
@ -876,8 +890,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully * Volume unmounted successfully
*/ */
200: { 200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error'; status: 'mounted' | 'unmounted' | 'error';
error?: string;
}; };
}; };
@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = {
* Volume health check result * Volume health check result
*/ */
200: { 200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error'; status: 'mounted' | 'unmounted' | 'error';
error?: string;
}; };
}; };
@ -932,7 +946,7 @@ export type ListFilesResponses = {
files: Array<{ files: Array<{
name: string; name: string;
path: string; path: string;
type: 'file' | 'directory'; type: 'directory' | 'file';
size?: number; size?: number;
modifiedAt?: number; modifiedAt?: number;
}>; }>;
@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = {
directories: Array<{ directories: Array<{
name: string; name: string;
path: string; path: string;
type: 'file' | 'directory'; type: 'directory';
size?: number; size?: unknown;
modifiedAt?: number; modifiedAt?: number;
}>; }>;
path: string; path: string;
@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -1319,6 +1334,7 @@ export type CreateRepositoryData = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -2248,6 +2267,7 @@ export type RestoreSnapshotData = {
excludeXattr?: Array<string>; excludeXattr?: Array<string>;
delete?: boolean; delete?: boolean;
targetPath?: string; targetPath?: string;
targetAgentId?: string;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never'; overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
}; };
path: { path: {
@ -2259,13 +2279,11 @@ export type RestoreSnapshotData = {
export type RestoreSnapshotResponses = { export type RestoreSnapshotResponses = {
/** /**
* Snapshot restored successfully * Snapshot restore started
*/ */
200: { 202: {
success: boolean; restoreId: string;
message: string; status: 'started';
filesRestored: number;
filesSkipped: number;
}; };
}; };
@ -2434,6 +2452,20 @@ export type ListBackupSchedulesResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null; lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null; lastBackupError: string | null;
@ -2443,14 +2475,7 @@ export type ListBackupSchedulesResponses = {
volume: { volume: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -2465,6 +2490,7 @@ export type ListBackupSchedulesResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -2498,7 +2524,15 @@ export type ListBackupSchedulesResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
repository: { repository: {
@ -2650,6 +2684,7 @@ export type ListBackupSchedulesResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -2710,6 +2745,20 @@ export type CreateBackupScheduleData = {
oneFileSystem?: boolean; oneFileSystem?: boolean;
tags?: Array<string>; tags?: Array<string>;
customResticParams?: Array<string>; customResticParams?: Array<string>;
backupWebhooks?: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries?: number;
retryDelay?: number;
}; };
path?: never; path?: never;
query?: never; query?: never;
@ -2743,6 +2792,20 @@ export type CreateBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null; lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null; lastBackupError: string | null;
@ -2810,6 +2873,20 @@ export type GetBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null; lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null; lastBackupError: string | null;
@ -2819,14 +2896,7 @@ export type GetBackupScheduleResponses = {
volume: { volume: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -2841,6 +2911,7 @@ export type GetBackupScheduleResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -2874,7 +2945,15 @@ export type GetBackupScheduleResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
repository: { repository: {
@ -3026,6 +3105,7 @@ export type GetBackupScheduleResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -3085,6 +3165,20 @@ export type UpdateBackupScheduleData = {
oneFileSystem?: boolean; oneFileSystem?: boolean;
tags?: Array<string>; tags?: Array<string>;
customResticParams?: Array<string>; customResticParams?: Array<string>;
backupWebhooks?: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries?: number;
retryDelay?: number;
}; };
path: { path: {
shortId: string; shortId: string;
@ -3120,6 +3214,20 @@ export type UpdateBackupScheduleResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null; lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null; lastBackupError: string | null;
@ -3167,6 +3275,20 @@ export type GetBackupScheduleForVolumeResponses = {
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
oneFileSystem: boolean; oneFileSystem: boolean;
customResticParams: Array<string> | null; customResticParams: Array<string> | null;
backupWebhooks: {
pre: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
post: {
url: string;
headers?: Array<string>;
body?: string;
} | null;
} | null;
maxRetries: number;
retryDelay: number;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null; lastBackupStatus: 'success' | 'error' | 'in_progress' | 'warning' | null;
lastBackupError: string | null; lastBackupError: string | null;
@ -3176,14 +3298,7 @@ export type GetBackupScheduleForVolumeResponses = {
volume: { volume: {
id: number; id: number;
shortId: string; shortId: string;
provisioningId: string | null;
name: string; name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: { config: {
backend: 'nfs'; backend: 'nfs';
server: string; server: string;
@ -3198,6 +3313,7 @@ export type GetBackupScheduleForVolumeResponses = {
username?: string; username?: string;
password?: string; password?: string;
guest?: boolean; guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string; domain?: string;
port?: string | number; port?: string | number;
@ -3231,7 +3347,15 @@ export type GetBackupScheduleForVolumeResponses = {
readOnly?: boolean; readOnly?: boolean;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
}; };
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean; autoRemount: boolean;
}; };
repository: { repository: {
@ -3383,6 +3507,7 @@ export type GetBackupScheduleForVolumeResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -3512,6 +3637,9 @@ export type GetScheduleNotificationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -3526,7 +3654,6 @@ export type GetScheduleNotificationsResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -3614,6 +3741,9 @@ export type UpdateScheduleNotificationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -3628,7 +3758,6 @@ export type UpdateScheduleNotificationsResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -3853,6 +3982,7 @@ export type GetScheduleMirrorsResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -4065,6 +4195,7 @@ export type UpdateScheduleMirrorsResponses = {
privateKey: string; privateKey: string;
skipHostKeyCheck?: boolean; skipHostKeyCheck?: boolean;
knownHosts?: string; knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean; isExistingRepository?: boolean;
customPassword?: string; customPassword?: string;
cacert?: string; cacert?: string;
@ -4102,6 +4233,63 @@ export type UpdateScheduleMirrorsResponses = {
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses]; export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
export type GetMirrorSyncStatusData = {
body?: never;
path: {
shortId: string;
mirrorShortId: string;
};
query?: never;
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status';
};
export type GetMirrorSyncStatusResponses = {
/**
* Mirror sync status with missing snapshots
*/
200: {
sourceCount: number;
mirrorCount: number;
missingSnapshots: Array<{
short_id: string;
time: string;
size: number;
}>;
};
};
export type GetMirrorSyncStatusResponse = GetMirrorSyncStatusResponses[keyof GetMirrorSyncStatusResponses];
export type SyncMirrorData = {
body: {
snapshotIds?: Array<string>;
};
path: {
shortId: string;
mirrorShortId: string;
};
query?: never;
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync';
};
export type SyncMirrorErrors = {
/**
* Mirror is already syncing
*/
409: unknown;
};
export type SyncMirrorResponses = {
/**
* Mirror sync started successfully
*/
200: {
success: boolean;
};
};
export type SyncMirrorResponse = SyncMirrorResponses[keyof SyncMirrorResponses];
export type GetMirrorCompatibilityData = { export type GetMirrorCompatibilityData = {
body?: never; body?: never;
path: { path: {
@ -4189,6 +4377,9 @@ export type ListNotificationDestinationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4203,7 +4394,6 @@ export type ListNotificationDestinationsResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -4273,7 +4463,6 @@ export type CreateNotificationDestinationData = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -4334,6 +4523,9 @@ export type CreateNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4348,7 +4540,6 @@ export type CreateNotificationDestinationResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -4453,6 +4644,9 @@ export type GetNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4467,7 +4661,6 @@ export type GetNotificationDestinationResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -4538,7 +4731,6 @@ export type UpdateNotificationDestinationData = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {
@ -4608,6 +4800,9 @@ export type UpdateNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4622,7 +4817,6 @@ export type UpdateNotificationDestinationResponses = {
} | { } | {
type: 'slack'; type: 'slack';
webhookUrl: string; webhookUrl: string;
channel?: string;
username?: string; username?: string;
iconEmoji?: string; iconEmoji?: string;
} | { } | {

View file

@ -0,0 +1,54 @@
import type { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { cn } from "~/client/lib/utils";
export function DataTableSortHeader<TData, TValue>({
column,
title,
sortDirection,
center = false,
}: {
column: Column<TData, TValue>;
title: string;
sortDirection: false | "asc" | "desc";
center?: boolean;
}) {
const icon =
sortDirection === "desc" ? (
<ArrowDown className="ml-2 h-3.5 w-3.5" />
) : sortDirection === "asc" ? (
<ArrowUp className="ml-2 h-3.5 w-3.5" />
) : (
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
);
const iconVisibility = sortDirection ? "" : "lg:invisible lg:group-hover/sort:visible";
if (center) {
return (
<Button
type="button"
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto! w-full! p-0! font-inherit hover:bg-transparent uppercase group/sort relative"
>
<span className="relative flex w-full items-center justify-center">
{title}
<span className={cn("lg:absolute lg:-right-6 lg:top-1/2 lg:-translate-y-1/2", iconVisibility)}>{icon}</span>
</span>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto! p-0! font-inherit hover:bg-transparent uppercase group/sort"
>
{title}
<span className={iconVisibility}>{icon}</span>
</Button>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useHotkey } from "@tanstack/react-hotkeys"; import { useHotkeySequence } from "@tanstack/react-hotkeys";
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DevPanel } from "./dev-panel"; import { DevPanel } from "./dev-panel";
@ -10,7 +10,12 @@ export function DevPanelListener() {
...getDevPanelOptions(), ...getDevPanelOptions(),
}); });
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true }); useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
enabled: !!devPanelStatus?.enabled,
preventDefault: true,
ignoreInputs: true,
timeout: 1000,
});
if (!devPanelStatus?.enabled) { if (!devPanelStatus?.enabled) {
return null; return null;

View file

@ -224,29 +224,6 @@ describe("SnapshotTreeBrowser", () => {
}); });
}); });
test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();
renderSnapshotTreeBrowser();
const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;
await userEvent.hover(row);
await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});
expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
test("shows the query root contents when display and query roots differ", async () => { test("shows the query root contents when display and query roots differ", async () => {
mockListSnapshotFiles(); mockListSnapshotFiles();

View file

@ -5,7 +5,6 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
function createPathPrefixFns(basePath: string) { function createPathPrefixFns(basePath: string) {
return { return {
@ -84,16 +83,6 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
}), }),
); );
}, },
prefetchFolder: (displayPath) => {
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: displayPathFns, pathTransform: displayPathFns,
}); });

View file

@ -64,7 +64,7 @@ export function Layout({ loaderData }: Props) {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
href="https://github.com/nicotsx/zerobyte/issues/new" href="https://github.com/nicotsx/zerobyte/issues/new/choose"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={buttonVariants({ className={buttonVariants({

View file

@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{label}</p> <p className="capitalize">{label}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -10,7 +10,7 @@ type ThemeContextValue = {
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
export const THEME_COOKIE_NAME = "theme"; export const THEME_COOKIE_NAME = "theme";
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
const DEFAULT_THEME: Theme = "dark"; const DEFAULT_THEME: Theme = "dark";
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) { export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react"; import type * as React from "react";
import { buttonVariants } from "~/client/components/ui/button"; import { buttonVariants } from "~/client/components/ui/button";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
@ -12,17 +13,16 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return ( return (
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn("text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0", className)}
"text-muted-foreground flex items-center gap-1.5 text-sm sm:gap-2.5 min-w-0",
className,
)}
{...props} {...props}
/> />
); );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />; return (
<li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5 min-w-0", className)} {...props} />
);
} }
function BreadcrumbLink({ function BreadcrumbLink({

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
// @ts-nocheck // @ts-nocheck
// biome-ignore-all lint: reason // biome-ignore-all lint: reason
import * as React from "react"; import * as React from "react";
@ -9,6 +10,7 @@ import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const; const THEMES = { light: "", dark: ".dark" } as const;
// fallow-ignore-next-line unused-type
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode; label?: React.ReactNode;

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import * as React from "react"; import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import * as React from "react"; import * as React from "react";
import type * as LabelPrimitive from "@radix-ui/react-label"; import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";

View file

@ -1,6 +1,5 @@
import * as React from "react"; import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp"; import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
@ -55,12 +54,8 @@ function InputOTPSlot({
); );
} }
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
return ( return <hr data-slot="input-otp-separator" {...props} />;
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
} }
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@ -14,7 +15,7 @@ export const isStoredSecretValue = (value?: string): boolean => {
return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:"); return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:");
}; };
type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type"> type SecretInputProps = Omit<React.ComponentProps<typeof Input>, "type">;
export const SecretInput = ({ className, value, ...props }: SecretInputProps) => { export const SecretInput = ({ className, value, ...props }: SecretInputProps) => {
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
@ -36,12 +37,7 @@ export const SecretInput = ({ className, value, ...props }: SecretInputProps) =>
return ( return (
<div className="relative" data-secret-input> <div className="relative" data-secret-input>
<Input <Input {...props} value={value} type={type} className={cn(!showAsPlaintext && "pr-10", className)} />
{...props}
value={value}
type={type}
className={cn(!showAsPlaintext && "pr-10", className)}
/>
{!showAsPlaintext && ( {!showAsPlaintext && (
<Button <Button
type="button" type="button"

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import * as React from "react"; import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
"use client"; "use client";
import type * as React from "react"; import type * as React from "react";

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
"use client"; "use client";
import * as React from "react"; import * as React from "react";

View file

@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "dark" } = useTheme(); const { theme } = useTheme();
return ( return (
<Sonner <Sonner

View file

@ -1,3 +1,4 @@
// fallow-ignore-file unused-export
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";

View file

@ -1,5 +1,5 @@
import { Cloud, Folder, Server } from "lucide-react"; import { Cloud, Folder, Server } from "lucide-react";
import type { BackendType } from "~/schemas/volumes"; import type { BackendType } from "@zerobyte/contracts/volumes";
type VolumeIconProps = { type VolumeIconProps = {
backend: BackendType; backend: BackendType;

View file

@ -0,0 +1,25 @@
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
type WebhookRequestPreviewProps = {
method: string;
url?: string;
contentType?: string;
headers?: string[];
body: string;
};
export const WebhookRequestPreview = ({ method, url, contentType, headers, body }: WebhookRequestPreviewProps) => {
const headerLines = [contentType ? `Content-Type: ${contentType}` : undefined, ...(headers ?? [])].filter(Boolean);
const previewCode = `${method} ${url || "https://api.example.com/webhook"}${headerLines.length > 0 ? `\n${headerLines.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
);
};

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getIsSecureContextForRequest, isSecureContextOrigin } from "../is-secure-context";
describe("isSecureContextOrigin", () => {
test("treats HTTPS and loopback HTTP origins as secure contexts", () => {
expect(isSecureContextOrigin("https://example.com")).toBe(true);
expect(isSecureContextOrigin("http://localhost:3000")).toBe(true);
expect(isSecureContextOrigin("http://app.localhost")).toBe(true);
expect(isSecureContextOrigin("http://127.0.0.1:3000")).toBe(true);
expect(isSecureContextOrigin("http://[::1]:3000")).toBe(true);
});
test("treats non-local HTTP origins as insecure contexts", () => {
expect(isSecureContextOrigin("http://example.com")).toBe(false);
expect(isSecureContextOrigin("http://127.example.com")).toBe(false);
});
});
describe("getIsSecureContextForRequest", () => {
test("uses forwarded protocol and host when present", () => {
const request = new Request("http://internal.local/settings", {
headers: {
host: "internal.local",
"x-forwarded-host": "zerobyte.example.com",
"x-forwarded-proto": "https",
},
});
expect(getIsSecureContextForRequest(request)).toBe(true);
});
test("uses the request URL when forwarded headers are absent", () => {
expect(getIsSecureContextForRequest(new Request("http://zerobyte.example.com/settings"))).toBe(false);
expect(getIsSecureContextForRequest(new Request("https://zerobyte.example.com/settings"))).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
import { createIsomorphicFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import ipaddr from "ipaddr.js";
const getFirstHeaderValue = (value: string | null) => value?.split(",")[0]?.trim();
const isLoopbackIp = (hostname: string) => {
try {
return ipaddr.parse(hostname).range() === "loopback";
} catch {
return false;
}
};
const getRequestOrigin = (request: Request) => {
const requestUrl = new URL(request.url);
const forwardedProto = getFirstHeaderValue(request.headers.get("x-forwarded-proto"));
const forwardedHost = getFirstHeaderValue(request.headers.get("x-forwarded-host"));
const host = forwardedHost || getFirstHeaderValue(request.headers.get("host"));
if (!host) {
return requestUrl.origin;
}
const protocol = (forwardedProto || requestUrl.protocol).replace(/:$/, "");
return `${protocol}://${host}`;
};
export const isSecureContextOrigin = (origin: string) => {
const url = new URL(origin);
if (url.protocol === "https:") {
return true;
}
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackIp(hostname);
};
export const getIsSecureContextForRequest = (request: Request) => isSecureContextOrigin(getRequestOrigin(request));
export const getIsSecureContext = createIsomorphicFn()
.server(() => {
try {
return getIsSecureContextForRequest(getRequest());
} catch {
return true;
}
})
.client(() => window.isSecureContext ?? true);

View file

@ -28,16 +28,15 @@ const invalidatingEvents = new Set<ServerEventType>([
"backup:completed", "backup:completed",
"volume:updated", "volume:updated",
"volume:status_changed", "volume:status_changed",
"notification:updated",
"mirror:completed", "mirror:completed",
"doctor:started", "doctor:started",
"doctor:completed", "doctor:completed",
"doctor:cancelled", "doctor:cancelled",
]); ]);
export type RestoreEvent = ServerEventsPayloadMap["restore:started"] | ServerEventsPayloadMap["restore:completed"];
export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"]; export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"]; export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
const sharedState: SharedServerEventsState = { const sharedState: SharedServerEventsState = {
eventSource: null, eventSource: null,

View file

@ -6,7 +6,8 @@ export function useUpdates() {
...getUpdatesOptions(), ...getUpdatesOptions(),
staleTime: 60 * 60 * 1000, staleTime: 60 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000, gcTime: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false, refetchOnMount: "always",
refetchOnWindowFocus: "always",
}); });
return { return {

View file

@ -7,6 +7,7 @@ import {
inferAdditionalFields, inferAdditionalFields,
} from "better-auth/client/plugins"; } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client"; import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth"; import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
@ -17,5 +18,6 @@ export const authClient = createAuthClient({
organizationClient(), organizationClient(),
ssoClient(), ssoClient(),
twoFactorClient(), twoFactorClient(),
passkeyClient(),
], ],
}); });

View file

@ -6,7 +6,7 @@ export type DateInput = Date | string | number | null | undefined;
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const; export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
export type DateFormatPreference = (typeof DATE_FORMATS)[number]; export type DateFormatPreference = (typeof DATE_FORMATS)[number];
export const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY"; const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
export const TIME_FORMATS = ["12h", "24h"] as const; export const TIME_FORMATS = ["12h", "24h"] as const;
export type TimeFormatPreference = (typeof TIME_FORMATS)[number]; export type TimeFormatPreference = (typeof TIME_FORMATS)[number];

View file

@ -3,7 +3,7 @@ import { unlockRepository } from "~/client/api-client/sdk.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Unlock } from "lucide-react"; import { Unlock } from "lucide-react";
export const isLockError = (error: unknown): boolean => { const isLockError = (error: unknown): boolean => {
const errorMessage = parseError(error)?.message || ""; const errorMessage = parseError(error)?.message || "";
return ( return (
@ -25,7 +25,7 @@ export const parseError = (error?: unknown) => {
return undefined; return undefined;
}; };
export const showLockErrorToast = (repositoryId: string, title: string) => { const showLockErrorToast = (repositoryId: string, title: string) => {
toast.error(title, { toast.error(title, {
description: description:
"The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.", "The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.",

View file

@ -1,6 +1,5 @@
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors"; import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
export type { LoginErrorCode } from "~/lib/sso-errors";
export { getLoginErrorDescription } from "~/lib/sso-errors"; export { getLoginErrorDescription } from "~/lib/sso-errors";
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES); const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);

View file

@ -6,29 +6,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Converts an arbitrary string into a URL-safe slug:
* - lowercase
* - trims whitespace
* - replaces non-alphanumeric runs with "-"
* - collapses multiple hyphens
* - trims leading/trailing hyphens
*/
/**
* Live slugify for UI: lowercases, normalizes dashes, replaces invalid runs with "-",
* collapses repeats, but DOES NOT trim leading/trailing hyphens so the user can type
* spaces/dashes progressively while editing.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[ ]/g, "-")
.replace(/[^a-z0-9_-]+/g, "")
.replace(/[-]{2,}/g, "-")
.replace(/[_]{2,}/g, "_")
.trim();
}
export function safeJsonParse<T>(input: string): T | null { export function safeJsonParse<T>(input: string): T | null {
try { try {
return JSON.parse(input) as T; return JSON.parse(input) as T;

View file

@ -0,0 +1,35 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
import { getLoginOptions } from "~/server/lib/functions/login-options";
import { PasskeySignInButton } from "./passkey-sign-in-button";
type AlternativeSignInSectionProps = {
onPasskeySignIn: () => Promise<void>;
};
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
const getOptions = useServerFn(getLoginOptions);
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const { data: loginOptions } = useSuspenseQuery({
queryKey: ["login-options"],
queryFn: getOptions,
});
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
<SsoLoginButtons providers={ssoProviders.providers} />
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Fingerprint } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
type PasskeySignInButtonProps = {
onSignIn: () => Promise<void>;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
return code as LoginErrorCode;
}
return PASSKEY_LOGIN_FAILED_ERROR;
}
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
const navigate = useNavigate();
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
mutationFn: async () => {
const { error } = await authClient.signIn.passkey();
if (error) throw error;
await onSignIn();
},
onError: (error) => {
logger.error(error);
void navigate({
to: "/login",
search: {
error: getPasskeyLoginErrorCode(error.code),
},
});
},
});
return (
<Button
type="button"
variant="outline"
className="w-full"
loading={passkeyLoginMutation.isPending}
disabled={passkeyLoginMutation.isPending}
onClick={() => passkeyLoginMutation.mutate()}
>
<Fingerprint className="h-4 w-4 mr-2" />
Sign in with passkey
</Button>
);
}

View file

@ -1,16 +1,49 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server"; import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils"; import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
import { getLoginErrorDescription, PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
mockNavigate: vi.fn(async () => {}),
mockPasskeySignIn: vi.fn(
async (): Promise<{
data: unknown;
error: { code: string; message: string } | null;
}> => ({ data: null, error: null }),
),
}));
vi.mock("@tanstack/react-router", async (importOriginal) => { vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>(); const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return { return {
...actual, ...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
}; };
}); });
vi.mock("@tanstack/react-start", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-start")>();
return {
...actual,
useServerFn: (fn: unknown) => fn,
};
});
vi.mock("~/server/lib/functions/login-options", () => ({
getLoginOptions: mockGetLoginOptions,
}));
vi.mock("~/client/lib/auth-client", () => ({
authClient: {
signIn: {
passkey: mockPasskeySignIn,
},
},
}));
import { LoginPage } from "../login"; import { LoginPage } from "../login";
const inviteOnlyMessage = const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
@ -29,6 +62,12 @@ const mockSsoProvidersRequest = (
}; };
afterEach(() => { afterEach(() => {
mockGetLoginOptions.mockClear();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup(); cleanup();
}); });
@ -81,6 +120,14 @@ describe("LoginPage", () => {
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
}); });
test("shows passkey login failure message when passkey returns the login error code", async () => {
mockSsoProvidersRequest();
render(<LoginPage error={"PASSKEY_LOGIN_FAILED"} />, { withSuspense: true });
expect(await screen.findByText(getLoginErrorDescription(PASSKEY_LOGIN_FAILED_ERROR))).toBeTruthy();
});
test("does not show error message for invalid error codes", async () => { test("does not show error message for invalid error codes", async () => {
mockSsoProvidersRequest(); mockSsoProvidersRequest();
@ -90,11 +137,141 @@ describe("LoginPage", () => {
expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
}); });
test("renders available SSO providers from the real SSO section", async () => { test("renders available SSO providers from the alternative sign-in section", async () => {
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]); mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
render(<LoginPage />, { withSuspense: true }); render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy(); expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
}); });
test("renders passkey sign-in when an active user has a passkey", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy();
});
test("redirects passkey verification failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("redirects unauthorized passkey failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("preserves specific passkey login error codes", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "ERROR_INVALID_RP_ID",
},
});
});
});
test("redirects conditional passkey autofill failures to the login error box", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "PASSKEY_LOGIN_FAILED",
},
});
});
});
test("ignores conditional passkey autofill cancellation", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
});
expect(mockNavigate).not.toHaveBeenCalled();
});
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
mockSsoProvidersRequest();
render(<LoginPage />, { withSuspense: true });
await screen.findByText("Login to your account");
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
});
}); });

View file

@ -8,11 +8,25 @@ import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import {
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE,
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME,
} from "~/lib/recovery-key-skip";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
export function DownloadRecoveryKeyPage() { const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
hasCredentialPassword: boolean;
userId: string | null;
};
export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
const downloadResticPassword = useMutation({ const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(), ...downloadResticPasswordMutation(),
@ -25,13 +39,16 @@ export function DownloadRecoveryKeyPage() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(url); window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Recovery key downloaded successfully!"); toast.success("Recovery key downloaded successfully!");
setBlockedMessage(null);
void navigate({ to: "/volumes", replace: true }); void navigate({ to: "/volumes", replace: true });
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to download recovery key", { description: error.message }); const message = parseError(error)?.message;
setBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download recovery key", { description: message });
}, },
}); });
@ -43,11 +60,15 @@ export function DownloadRecoveryKeyPage() {
return; return;
} }
downloadResticPassword.mutate({ setBlockedMessage(null);
body: { downloadResticPassword.mutate({ body: { password } });
password, };
},
}); const handleSkip = () => {
if (!userId) return;
document.cookie = `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}; path=/; max-age=${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE}`;
void navigate({ to: "/volumes", replace: true });
}; };
return ( return (
@ -59,30 +80,56 @@ export function DownloadRecoveryKeyPage() {
<AlertTriangle className="size-5" /> <AlertTriangle className="size-5" />
<AlertTitle>Important: Save This File Securely</AlertTitle> <AlertTitle>Important: Save This File Securely</AlertTitle>
<AlertDescription> <AlertDescription>
Your Restic password is essential for recovering your backup data. If you lose access to this server without Your Restic password is essential for recovering your backup data. If you previously downloaded this
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage. file, replace that saved copy with the new download. If you lose access to this server without this
file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> {(!hasCredentialPassword || blockedMessage) && (
<Label htmlFor="password">Confirm Your Password</Label> <Alert variant="warning">
<Input <AlertTriangle className="size-5" />
id="password" <AlertTitle>Local password required</AlertTitle>
type="password" <AlertDescription>
value={password} {blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
onChange={(e) => setPassword(e.target.value)} </AlertDescription>
placeholder="Enter your password" </Alert>
required )}
disabled={downloadResticPassword.isPending}
/> {hasCredentialPassword && (
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p> <div className="space-y-2">
</div> <Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">
Enter your account password to download the recovery key
</p>
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full"> {hasCredentialPassword && (
<Download size={16} className="mr-2" /> <Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
Download Recovery Key <Download size={16} className="mr-2" />
Download Recovery Key
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={handleSkip}
disabled={downloadResticPassword.isPending}
className="w-full"
>
Skip
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout"; import { AuthLayout } from "~/client/components/auth-layout";
@ -10,12 +10,14 @@ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/clie
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger"; import { logger } from "~/client/lib/logger";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors"; import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username"; import { normalizeUsername } from "~/lib/username";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section"; import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
import { z } from "zod"; import { z } from "zod";
const loginSchema = z.object({ const loginSchema = z.object({
@ -32,6 +34,23 @@ type LoginPageProps = {
error?: string; error?: string;
}; };
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
}
function hasSkippedRecoveryKeyDownload(userId: string) {
return document.cookie
.split(";")
.some((cookie) => cookie.trim() === `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}`);
}
export function LoginPage({ error }: LoginPageProps = {}) { export function LoginPage({ error }: LoginPageProps = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false); const [showResetDialog, setShowResetDialog] = useState(false);
@ -43,6 +62,52 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error); const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null; const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const navigateAfterLogin = useCallback(async () => {
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
}, [navigate]);
useEffect(() => {
const autoSignIn = async () => {
if (
typeof PublicKeyCredential === "undefined" ||
!PublicKeyCredential.isConditionalMediationAvailable ||
!(await PublicKeyCredential.isConditionalMediationAvailable())
) {
return;
}
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
});
if (isPasskeyVerificationFailure(error)) {
void navigate({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
return;
}
if (data) {
await navigateAfterLogin();
}
};
void autoSignIn();
}, [navigate, navigateAfterLogin]);
const form = useForm<LoginFormValues>({ const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
@ -76,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return; return;
} }
const d = await authClient.getSession(); await navigateAfterLogin();
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
}; };
const handleVerify2FA = async () => { const handleVerify2FA = async () => {
@ -113,7 +173,11 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (data) { if (data) {
toast.success("Login successful"); toast.success("Login successful");
const session = await authClient.getSession(); const session = await authClient.getSession();
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) { if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" }); void navigate({ to: "/download-recovery-key" });
} else { } else {
void navigate({ to: "/volumes" }); void navigate({ to: "/volumes" });
@ -130,7 +194,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
if (requires2FA) { if (requires2FA) {
return ( return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app"> <AuthLayout
title="Two-Factor Authentication"
description="Enter the 6-digit code from your authenticator app"
>
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4 flex flex-col items-center"> <div className="space-y-4 flex flex-col items-center">
<Label htmlFor="totp-code">Authentication code</Label> <Label htmlFor="totp-code">Authentication code</Label>
@ -161,6 +228,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<input <input
type="checkbox" type="checkbox"
id="trust-device" id="trust-device"
aria-label="Trust this device for 30 days"
checked={trustDevice} checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)} onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4" className="h-4 w-4"
@ -199,7 +267,11 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account"> <AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}> <div
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
hidden: !errorDescription,
})}
>
{errorDescription} {errorDescription}
</div> </div>
<FormField <FormField
@ -209,7 +281,13 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} /> <Input
{...field}
type="text"
placeholder="admin"
disabled={isLoggingIn}
autoComplete="username webauthn"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -231,7 +309,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</button> </button>
</div> </div>
<FormControl> <FormControl>
<Input {...field} type="password" disabled={isLoggingIn} /> <Input
{...field}
type="password"
disabled={isLoggingIn}
autoComplete="current-password webauthn"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -243,7 +326,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form> </form>
</Form> </Form>
<SsoLoginSection /> <AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} /> <ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout> </AuthLayout>

View file

@ -10,7 +10,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "~/client/components/ui/form"; } from "~/client/components/ui/form";
import { authMiddleware } from "~/middleware/auth";
import { AuthLayout } from "~/client/components/auth-layout"; import { AuthLayout } from "~/client/components/auth-layout";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -22,8 +21,6 @@ import { normalizeUsername } from "~/lib/username";
import { z } from "zod"; import { z } from "zod";
import { inferDateTimePreferences } from "~/client/lib/datetime"; import { inferDateTimePreferences } from "~/client/lib/datetime";
export const clientMiddleware = [authMiddleware];
const onboardingSchema = z.object({ const onboardingSchema = z.object({
username: z.string().min(2).max(30).transform(normalizeUsername), username: z.string().min(2).max(30).transform(normalizeUsername),
email: z email: z

View file

@ -1,35 +1,165 @@
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea"; import { Textarea } from "~/client/components/ui/textarea";
import type { UseFormReturn } from "react-hook-form"; import { type UseFormReturn } from "react-hook-form";
import type { InternalFormValues } from "./types"; import type { InternalFormValues } from "./types";
import { Input } from "~/client/components/ui/input";
type AdvancedSectionProps = { type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>; form: UseFormReturn<InternalFormValues>;
}; };
export const AdvancedSection = ({ form }: AdvancedSectionProps) => { type WebhookFieldsProps = {
form: UseFormReturn<InternalFormValues>;
phase: "pre" | "post";
urlPlaceholder: string;
bodyPlaceholder: string;
description: string;
};
const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, description }: WebhookFieldsProps) => {
const labelPrefix = phase === "pre" ? "Pre-backup" : "Post-backup";
const fieldPrefix = phase === "pre" ? "preBackupWebhook" : "postBackupWebhook";
return ( return (
<FormField <>
control={form.control} <FormField
name="customResticParamsText" control={form.control}
render={({ field }) => ( name={`${fieldPrefix}Url`}
<FormItem> render={({ field }) => (
<FormLabel>Custom restic parameters</FormLabel> <FormItem>
<FormControl> <FormLabel>{labelPrefix} webhook</FormLabel>
<Textarea <FormControl>
{...field} <Input {...field} type="url" placeholder={urlPlaceholder} />
placeholder="--exclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8" </FormControl>
className="font-mono text-sm min-h-24" <FormDescription>
/> {description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
Advanced: enter one restic flag per line (e.g.{" "} </FormItem>
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list is )}
accepted. />
</FormDescription> <FormField
<FormMessage /> control={form.control}
</FormItem> name={`${fieldPrefix}HeadersText`}
)} render={({ field }) => (
/> <FormItem>
<FormLabel>{labelPrefix} webhook headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${fieldPrefix}Body`}
render={({ field }) => (
<FormItem>
<FormLabel>{labelPrefix} webhook body</FormLabel>
<FormControl>
<Textarea {...field} placeholder={bodyPlaceholder} className="font-mono text-sm min-h-24" />
</FormControl>
<FormDescription>Optional raw POST body. Leave empty to send the backup context JSON.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return (
<>
<FormField
control={form.control}
name="maxRetries"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum retries</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={32}
value={field.value ?? ""}
placeholder="e.g., 2"
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>Maximum number of retry attempts if a backup fails (default: 2).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retryDelay"
render={({ field }) => (
<FormItem>
<FormLabel>Retry delay</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
max={1440}
step={1}
placeholder="e.g., 15"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>Delay in minutes before retrying a failed backup (default: 15 minutes).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<WebhookFields
form={form}
phase="pre"
urlPlaceholder="http://host.docker.internal:8080/stop"
bodyPlaceholder='{"action":"stop"}'
description="Called with POST before restic starts. A non-2xx response stops the backup."
/>
<WebhookFields
form={form}
phase="post"
urlPlaceholder="http://host.docker.internal:8080/start"
bodyPlaceholder='{"action":"start"}'
description="Called with POST after restic finishes, including failed or cancelled runs."
/>
<FormField
control={form.control}
name="customResticParamsText"
render={({ field }) => (
<FormItem>
<FormLabel>Custom restic parameters</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="--exclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
Advanced: enter one restic flag per line (e.g.{" "}
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list
is accepted.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
); );
}; };

View file

@ -14,7 +14,7 @@ import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section"; import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section"; import { SummarySection } from "./summary-section";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types"; import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues, parseMultilineEntries } from "./utils"; import { backupScheduleToFormValues, parseMultilineEntries, toWebhookConfig } from "./utils";
export type { BackupScheduleFormValues }; export type { BackupScheduleFormValues };
@ -44,12 +44,26 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
customResticParamsText, customResticParamsText,
includePaths, includePaths,
cronExpression, cronExpression,
maxRetries,
retryDelay,
preBackupWebhookUrl,
preBackupWebhookHeadersText,
preBackupWebhookBody,
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
...rest ...rest
} = data; } = data;
const excludePatterns = parseMultilineEntries(excludePatternsText); const excludePatterns = parseMultilineEntries(excludePatternsText);
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText); const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
const parsedIncludePatterns = parseMultilineEntries(includePatterns); const parsedIncludePatterns = parseMultilineEntries(includePatterns);
const customResticParams = parseMultilineEntries(customResticParamsText); const customResticParams = parseMultilineEntries(customResticParamsText);
const preBackupWebhook = toWebhookConfig(preBackupWebhookUrl, preBackupWebhookHeadersText, preBackupWebhookBody);
const postBackupWebhook = toWebhookConfig(
postBackupWebhookUrl,
postBackupWebhookHeadersText,
postBackupWebhookBody,
);
onSubmit({ onSubmit({
...rest, ...rest,
@ -59,6 +73,10 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatterns, excludePatterns,
excludeIfPresent, excludeIfPresent,
customResticParams, customResticParams,
backupWebhooks:
preBackupWebhook || postBackupWebhook ? { pre: preBackupWebhook, post: postBackupWebhook } : null,
maxRetries,
retryDelay,
}); });
}, },
[onSubmit], [onSubmit],

View file

@ -1,4 +1,19 @@
import { z } from "zod"; import { z } from "zod";
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
const webhookHeadersSchema = z.string().refine(
(value) =>
value
.split("\n")
.map((header) => header.trim())
.filter(Boolean)
.every((header) => {
const [key, value] = header.split(":", 2);
return /^[A-Za-z0-9-]+$/.test(key.trim()) && (value?.trim().length ?? 0) > 0;
}),
{ message: "Headers must use non-empty Key: Value format with valid header names" },
);
export const internalFormSchema = z.object({ export const internalFormSchema = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
@ -20,6 +35,14 @@ export const internalFormSchema = z.object({
keepYearly: z.number().optional(), keepYearly: z.number().optional(),
oneFileSystem: z.boolean().optional(), oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(), customResticParamsText: z.string().optional(),
preBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
preBackupWebhookHeadersText: webhookHeadersSchema.optional(),
preBackupWebhookBody: z.string().optional(),
postBackupWebhookUrl: z.union([z.url(), z.literal("")]).optional(),
postBackupWebhookHeadersText: webhookHeadersSchema.optional(),
postBackupWebhookBody: z.string().optional(),
maxRetries: z.number().min(0).max(32).optional(),
retryDelay: z.number().min(1).max(1440).optional(),
}); });
export const weeklyDays = [ export const weeklyDays = [
@ -36,10 +59,20 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit< export type BackupScheduleFormValues = Omit<
InternalFormValues, InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText" | "excludePatternsText"
| "excludeIfPresentText"
| "includePatterns"
| "customResticParamsText"
| "preBackupWebhookUrl"
| "preBackupWebhookHeadersText"
| "preBackupWebhookBody"
| "postBackupWebhookUrl"
| "postBackupWebhookHeadersText"
| "postBackupWebhookBody"
> & { > & {
excludePatterns?: string[]; excludePatterns?: string[];
excludeIfPresent?: string[]; excludeIfPresent?: string[];
includePatterns?: string[]; includePatterns?: string[];
customResticParams?: string[]; customResticParams?: string[];
backupWebhooks?: BackupWebhooks | null;
}; };

View file

@ -2,13 +2,29 @@ import type { BackupSchedule } from "~/client/lib/types";
import { cronToFormValues } from "../../lib/cron-utils"; import { cronToFormValues } from "../../lib/cron-utils";
import type { InternalFormValues } from "./types"; import type { InternalFormValues } from "./types";
export const parseMultilineEntries = (value?: string) => export const parseMultilineEntries = (value?: string) => {
value if (!value) {
? value return [];
.split("\n") }
.map((entry) => entry.trim())
.filter(Boolean) return value
: []; .split("\n")
.map((entry) => entry.trim())
.filter(Boolean);
};
export const toWebhookConfig = (url?: string, headers?: string, body?: string) => {
const trimmedUrl = url?.trim();
const parsedHeaders = parseMultilineEntries(headers);
return trimmedUrl
? {
url: trimmedUrl,
headers: parsedHeaders.length > 0 ? parsedHeaders : undefined,
body: body === "" ? undefined : body,
}
: null;
};
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => { export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) { if (!schedule) {
@ -26,6 +42,14 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false, oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "", customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
preBackupWebhookUrl: schedule.backupWebhooks?.pre?.url ?? "",
preBackupWebhookHeadersText: schedule.backupWebhooks?.pre?.headers?.join("\n") ?? "",
preBackupWebhookBody: schedule.backupWebhooks?.pre?.body ?? "",
postBackupWebhookUrl: schedule.backupWebhooks?.post?.url ?? "",
postBackupWebhookHeadersText: schedule.backupWebhooks?.post?.headers?.join("\n") ?? "",
postBackupWebhookBody: schedule.backupWebhooks?.post?.body ?? "",
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
...cronValues, ...cronValues,
...schedule.retentionPolicy, ...schedule.retentionPolicy,
}; };

View file

@ -1,5 +1,5 @@
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { Copy, Plus, Trash2 } from "lucide-react"; import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -8,21 +8,33 @@ import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge"; import { Badge } from "~/client/components/ui/badge";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { import {
getScheduleMirrorsOptions, getScheduleMirrorsOptions,
getMirrorCompatibilityOptions, getMirrorCompatibilityOptions,
getMirrorSyncStatusOptions,
updateScheduleMirrorsMutation, updateScheduleMirrorsMutation,
syncMirrorMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import { RepositoryIcon } from "~/client/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { StatusDot } from "~/client/components/status-dot"; import { StatusDot } from "~/client/components/status-dot";
import { ByteSize } from "~/client/components/bytes-size";
import { useServerEvents } from "~/client/hooks/use-server-events"; import { useServerEvents } from "~/client/hooks/use-server-events";
import { formatDistanceToNow } from "date-fns";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import type { GetScheduleMirrorsResponse } from "~/client/api-client"; import type { GetScheduleMirrorsResponse } from "~/client/api-client";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useTimeFormat } from "~/client/lib/datetime";
type Props = { type Props = {
scheduleShortId: string; scheduleShortId: string;
@ -56,11 +68,29 @@ const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
); );
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => { export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData)); const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false);
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const { addEventListener } = useServerEvents(); const { addEventListener } = useServerEvents();
const closeSyncDialog = () => {
setSyncDialogOpen(false);
setTimeout(() => {
setSyncDialogMirrorId(null);
setSelectedSnapshotIds(new Set());
}, 300);
};
const openSyncDialog = (mirrorShortId: string) => {
setSyncDialogOpen(true);
setSelectedSnapshotIds(new Set());
setSyncDialogMirrorId(mirrorShortId);
};
const { data: currentMirrors } = useSuspenseQuery({ const { data: currentMirrors } = useSuspenseQuery({
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }), ...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
}); });
@ -88,6 +118,47 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
}, },
}); });
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
...getMirrorSyncStatusOptions({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId ?? "" },
}),
enabled: syncDialogMirrorId !== null,
});
const toggleSnapshotSelection = (shortId: string) => {
setSelectedSnapshotIds((prev) => {
const next = new Set(prev);
if (next.has(shortId)) {
next.delete(shortId);
} else {
next.add(shortId);
}
return next;
});
};
const toggleAllSnapshots = () => {
if (!syncStatus) return;
if (selectedSnapshotIds.size === syncStatus.missingSnapshots.length) {
setSelectedSnapshotIds(new Set());
} else {
setSelectedSnapshotIds(new Set(syncStatus.missingSnapshots.map((s) => s.short_id)));
}
};
const triggerSync = useMutation({
...syncMirrorMutation(),
onSuccess: () => {
toast.success("Full sync started");
closeSyncDialog();
},
onError: (error) => {
toast.error("Failed to start sync", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => { const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>(); const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) { if (compatibility) {
@ -221,7 +292,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Syncing..."; return "Syncing...";
} }
if (assignment.lastCopyAt) { if (assignment.lastCopyAt) {
return formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true }); return formatTimeAgo(assignment.lastCopyAt);
} }
return "Never"; return "Never";
}; };
@ -239,6 +310,8 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Never copied"; return "Never copied";
}; };
const selectedMirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -358,23 +431,41 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot <div className="w-3 shrink-0 mr-1">
variant={getStatusVariant(assignment.lastCopyStatus)} <StatusDot
label={getStatusLabel(assignment)} variant={getStatusVariant(assignment.lastCopyStatus)}
animated={isSyncing(assignment)} label={getStatusLabel(assignment)}
/> animated={isSyncing(assignment)}
/>
</div>
<span className="text-sm text-muted-foreground">{getLabel(assignment)}</span> <span className="text-sm text-muted-foreground">{getLabel(assignment)}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <div className="flex items-center gap-1">
variant="ghost" <Tooltip>
size="icon" <TooltipTrigger asChild>
onClick={() => removeRepository(repository.shortId)} <Button
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline" variant="ghost"
> size="icon"
<Trash2 className="h-4 w-4" /> onClick={() => openSyncDialog(repository.shortId)}
</Button> disabled={isSyncing(assignment) || hasChanges}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Sync more snapshots</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@ -394,6 +485,89 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</Button> </Button>
</div> </div>
)} )}
<Dialog open={syncDialogOpen} onOpenChange={(open) => !open && closeSyncDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sync snapshots</DialogTitle>
<DialogDescription>
{`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`}
</DialogDescription>
</DialogHeader>
{isSyncStatusLoading && !syncStatus ? (
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">
All {syncStatus.sourceCount} snapshots are already synced to this mirror.
</div>
) : syncStatus ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{syncStatus.missingSnapshots.length} of {syncStatus.sourceCount} snapshots are missing in this mirror.
</p>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedSnapshotIds.size === syncStatus.missingSnapshots.length}
onCheckedChange={toggleAllSnapshots}
/>
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{syncStatus.missingSnapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="cursor-pointer"
onClick={() => toggleSnapshotSelection(snapshot.short_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedSnapshotIds.has(snapshot.short_id)}
onCheckedChange={() => toggleSnapshotSelection(snapshot.short_id)}
/>
</TableCell>
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
<TableCell className="text-sm">{formatDateTime(new Date(snapshot.time))}</TableCell>
<TableCell className="text-right text-sm">
<ByteSize bytes={snapshot.size} base={1024} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={closeSyncDialog}>
Cancel
</Button>
<Button
onClick={() => {
if (syncDialogMirrorId) {
triggerSync.mutate({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId },
body: { snapshotIds: Array.from(selectedSnapshotIds) },
});
}
}}
loading={triggerSync.isPending}
disabled={selectedSnapshotIds.size === 0}
>
Sync {selectedSnapshotIds.size} snapshots
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent> </CardContent>
</Card> </Card>
); );

View file

@ -1,4 +1,4 @@
export type CronFormValues = { type CronFormValues = {
frequency: string; frequency: string;
dailyTime?: string; dailyTime?: string;
weeklyDay?: string; weeklyDay?: string;

View file

@ -79,6 +79,7 @@ const schedule = {
excludeIfPresent: [], excludeIfPresent: [],
oneFileSystem: false, oneFileSystem: false,
customResticParams: [], customResticParams: [],
backupWebhooks: null,
}; };
const snapshot = { const snapshot = {

View file

@ -15,27 +15,33 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
import { EditBackupPage } from "../edit-backup"; import { EditBackupPage } from "../edit-backup";
afterEach(() => { const repository = { shortId: "repo-1", name: "Repo 1", type: "local" };
navigateMock.mockClear(); const volume = {
cleanup(); id: "volume-1",
}); shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
};
const volumeFilesResponse = {
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
};
test("submits the computed cron expression when saving a daily schedule", async () => { const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; cronExpression: string }) => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const submittedBody = new Promise<Record<string, unknown>>((resolve) => {
server.use( server.use(
http.get("/api/v1/backups/:shortId", () => { http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({ return HttpResponse.json({
shortId: "backup-1", shortId: "backup-1",
name: "Backup 1", name: "Backup 1",
enabled: true, enabled,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" }, repository,
volume: { volume,
id: "volume-1", cronExpression,
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null, retentionPolicy: null,
includePaths: ["/project"], includePaths: ["/project"],
includePatterns: [], includePatterns: [],
@ -43,20 +49,14 @@ test("submits the computed cron expression when saving a daily schedule", async
excludeIfPresent: [], excludeIfPresent: [],
oneFileSystem: false, oneFileSystem: false,
customResticParams: [], customResticParams: [],
backupWebhooks: null,
}); });
}), }),
http.get("/api/v1/repositories", () => { http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]); return HttpResponse.json([repository]);
}), }),
http.get("/api/v1/volumes/:shortId/files", () => { http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({ return HttpResponse.json(volumeFilesResponse);
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}), }),
http.patch("/api/v1/backups/:shortId", async ({ request }) => { http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>;
@ -64,13 +64,8 @@ test("submits the computed cron expression when saving a daily schedule", async
return HttpResponse.json({ return HttpResponse.json({
shortId: "backup-1", shortId: "backup-1",
volume: { volume,
id: "volume-1", repository,
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body, ...body,
}); });
}), }),
@ -79,6 +74,17 @@ test("submits the computed cron expression when saving a daily schedule", async
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true }); render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
return { submittedBody };
};
afterEach(() => {
navigateMock.mockClear();
cleanup();
});
test("submits the computed cron expression when saving a daily schedule", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({ await expect(submittedBody).resolves.toMatchObject({
@ -89,63 +95,7 @@ test("submits the computed cron expression when saving a daily schedule", async
}); });
test("disables the schedule when switching an enabled custom cron schedule to manual only", async () => { test("disables the schedule when switching an enabled custom cron schedule to manual only", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "*/13 * * * *" });
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
enabled: true,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "*/13 * * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
resolve(body);
return HttpResponse.json({
shortId: "backup-1",
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
);
});
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
const nativeFrequencySelect = (await screen.findAllByRole("combobox")) const nativeFrequencySelect = (await screen.findAllByRole("combobox"))
.at(1) .at(1)
@ -165,63 +115,7 @@ test("disables the schedule when switching an enabled custom cron schedule to ma
}); });
test("preserves a disabled schedule when saving a non-manual frequency", async () => { test("preserves a disabled schedule when saving a non-manual frequency", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => { const { submittedBody } = renderEditBackupPage({ enabled: false, cronExpression: "0 2 * * *" });
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
enabled: false,
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
resolve(body);
return HttpResponse.json({
shortId: "backup-1",
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
);
});
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
@ -231,3 +125,28 @@ test("preserves a disabled schedule when saving a non-manual frequency", async (
cronExpression: "00 02 * * *", cronExpression: "00 02 * * *",
}); });
}); });
test("submits webhook headers and body as plain config values", async () => {
const { submittedBody } = renderEditBackupPage({ enabled: true, cronExpression: "0 2 * * *" });
await userEvent.click(await screen.findByText("Advanced"));
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
target: { value: "Authorization: Bearer stop-token" },
});
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
target: { value: '{"action":"stop"}' },
});
await userEvent.click(screen.getByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
backupWebhooks: {
pre: {
url: "http://localhost:8080/stop",
headers: ["Authorization: Bearer stop-token"],
body: '{"action":"stop"}',
},
post: null,
},
});
});

View file

@ -63,9 +63,7 @@ export function ScheduleDetailsPage(props: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" }); const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>( const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null); const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
@ -79,7 +77,10 @@ export function ScheduleDetailsPage(props: Props) {
isLoading, isLoading,
failureReason, failureReason,
} = useQuery({ } = useQuery({
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }), ...listSnapshotsOptions({
path: { shortId: schedule.repository.shortId },
query: { backupId: schedule.shortId },
}),
initialData: loaderData.snapshots, initialData: loaderData.snapshots,
}); });
@ -167,6 +168,9 @@ export function ScheduleDetailsPage(props: Props) {
excludeIfPresent: schedule.excludeIfPresent || [], excludeIfPresent: schedule.excludeIfPresent || [],
oneFileSystem: schedule.oneFileSystem, oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [], customResticParams: schedule.customResticParams || [],
backupWebhooks: schedule.backupWebhooks,
maxRetries: schedule.maxRetries,
retryDelay: schedule.retryDelay,
}, },
}); });
}; };
@ -252,8 +256,8 @@ export function ScheduleDetailsPage(props: Props) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle> <AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the This action cannot be undone. This will permanently delete the snapshot and all its data
repository. from the repository.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View file

@ -75,6 +75,9 @@ export function CreateBackupPage() {
excludeIfPresent: formValues.excludeIfPresent, excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem, oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams, customResticParams: formValues.customResticParams,
backupWebhooks: formValues.backupWebhooks,
maxRetries: formValues.maxRetries,
retryDelay: formValues.retryDelay,
}, },
}); });
}; };

View file

@ -39,7 +39,7 @@ import {
const baseFields = { name: z.string().min(2).max(32) }; const baseFields = { name: z.string().min(2).max(32) };
export const formSchema = z.discriminatedUnion("type", [ const formSchema = z.discriminatedUnion("type", [
emailNotificationConfigSchema.extend(baseFields), emailNotificationConfigSchema.extend(baseFields),
slackNotificationConfigSchema.extend(baseFields), slackNotificationConfigSchema.extend(baseFields),
discordNotificationConfigSchema.extend(baseFields), discordNotificationConfigSchema.extend(baseFields),
@ -75,7 +75,6 @@ const defaultValuesForType = {
slack: { slack: {
type: "slack" as const, type: "slack" as const,
webhookUrl: "", webhookUrl: "",
channel: "",
username: "", username: "",
iconEmoji: "", iconEmoji: "",
}, },

View file

@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => {
> >
Shoutrrr documentation Shoutrrr documentation
</a> </a>
&nbsp;for supported services and URL formats. &nbsp;for supported services and URL formats. Custom HTTP, generic, and SMTP network targets must be listed
in WEBHOOK_ALLOWED_ORIGINS.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View file

@ -4,8 +4,7 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea"; import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block"; import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
import { Label } from "~/client/components/ui/label";
import type { NotificationFormValues } from "../create-notification-form"; import type { NotificationFormValues } from "../create-notification-form";
type Props = { type Props = {
@ -16,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
if (values.type !== "generic") return null; if (values.type !== "generic") return null;
const contentType = values.contentType || "application/json"; const contentType = values.contentType || "application/json";
const headers = values.headers || []; const headers = values.headers?.filter(Boolean) || [];
const useJson = values.useJson; const useJson = values.useJson;
const titleKey = values.titleKey || "title"; const titleKey = values.titleKey || "title";
const messageKey = values.messageKey || "message"; const messageKey = values.messageKey || "message";
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
body = "Notification message"; body = "Notification message";
} }
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return ( return (
<div className="space-y-2 pt-4 border-t"> <WebhookRequestPreview
<Label>Request Preview</Label> method={values.method || "POST"}
<CodeBlock code={previewCode} filename="HTTP Request" /> url={values.url}
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p> contentType={contentType}
</div> headers={headers}
body={body}
/>
); );
}; };
@ -63,7 +60,7 @@ export const GenericForm = ({ form }: Props) => {
<FormControl> <FormControl>
<Input {...field} placeholder="https://api.example.com/webhook" /> <Input {...field} placeholder="https://api.example.com/webhook" />
</FormControl> </FormControl>
<FormDescription>The target URL for the webhook.</FormDescription> <FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -113,10 +110,19 @@ export const GenericForm = ({ form }: Props) => {
{...field} {...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value" placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""} value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))} onChange={(e) =>
field.onChange(
e.target.value
.split("\n")
.map((header) => header.trim())
.filter(Boolean),
)
}
/> />
</FormControl> </FormControl>
<FormDescription>One header per line in Key: Value format.</FormDescription> <FormDescription>
One header per line in Key: Value format. Values are stored as plain text.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => {
<FormControl> <FormControl>
<Input {...field} placeholder="https://gotify.example.com" /> <Input {...field} placeholder="https://gotify.example.com" />
</FormControl> </FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription> <FormDescription>
Your self-hosted Gotify server URL. Its origin must be listed in WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => {
<FormControl> <FormControl>
<Input {...field} placeholder="https://ntfy.example.com" /> <Input {...field} placeholder="https://ntfy.example.com" />
</FormControl> </FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription> <FormDescription>
Leave empty to use ntfy.sh public service. Self-hosted ntfy origins must be listed in
WEBHOOK_ALLOWED_ORIGINS.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -27,20 +27,6 @@ export const SlackForm = ({ form }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="username" name="username"

View file

@ -40,6 +40,8 @@ import {
Lock, Lock,
Mail, Mail,
AtSign, AtSign,
AlertCircle,
Clock,
MessageSquare, MessageSquare,
Pencil, Pencil,
Server, Server,
@ -55,6 +57,7 @@ import type { GetNotificationDestinationResponse } from "~/client/api-client/typ
import { useTimeFormat } from "~/client/lib/datetime"; import { useTimeFormat } from "~/client/lib/datetime";
type NotificationConfig = GetNotificationDestinationResponse["config"]; type NotificationConfig = GetNotificationDestinationResponse["config"];
type NotificationStatus = GetNotificationDestinationResponse["status"];
type Props = { type Props = {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
@ -72,13 +75,23 @@ function ConfigRow({ icon, label, value, mono }: Props) {
); );
} }
function getStatusLabel(enabled: boolean, status: NotificationStatus) {
return enabled ? status : "disabled";
}
function getStatusClass(enabled: boolean, status: NotificationStatus) {
if (!enabled) return "bg-gray-500";
if (status === "healthy") return "bg-success";
if (status === "error") return "bg-red-500";
return "bg-yellow-500";
}
function NotificationConfigRows({ config }: { config: NotificationConfig }) { function NotificationConfigRows({ config }: { config: NotificationConfig }) {
switch (config.type) { switch (config.type) {
case "slack": case "slack":
return ( return (
<> <>
<ConfigRow icon={<Globe className="h-4 w-4" />} label="Webhook URL" value={config.webhookUrl} mono /> <ConfigRow icon={<Globe className="h-4 w-4" />} label="Webhook URL" value={config.webhookUrl} mono />
<ConfigRow icon={<Hash className="h-4 w-4" />} label="Channel" value={config.channel || "—"} />
<ConfigRow icon={<Bot className="h-4 w-4" />} label="Bot Username" value={config.username || "—"} /> <ConfigRow icon={<Bot className="h-4 w-4" />} label="Bot Username" value={config.username || "—"} />
<ConfigRow icon={<Smile className="h-4 w-4" />} label="Icon Emoji" value={config.iconEmoji || "—"} /> <ConfigRow icon={<Smile className="h-4 w-4" />} label="Icon Emoji" value={config.iconEmoji || "—"} />
</> </>
@ -229,13 +242,8 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<h2 className="text-lg font-semibold tracking-tight">{data.name}</h2> <h2 className="text-lg font-semibold tracking-tight">{data.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" /> <Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5"> <Badge variant="outline" className="capitalize gap-1.5">
<span <span className={cn("w-2 h-2 rounded-full shrink-0", getStatusClass(data.enabled, data.status))} />
className={cn("w-2 h-2 rounded-full shrink-0", { {getStatusLabel(data.enabled, data.status)}
"bg-success": data.enabled,
"bg-red-500": !data.enabled,
})}
/>
{data.enabled ? "Enabled" : "Disabled"}
</Badge> </Badge>
<Badge variant="secondary" className="capitalize"> <Badge variant="secondary" className="capitalize">
{data.type} {data.type}
@ -290,6 +298,34 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<NotificationConfigRows config={data.config} /> <NotificationConfigRows config={data.config} />
</div> </div>
</Card> </Card>
<Card className="px-6 py-6">
<CardTitle className="flex items-center gap-2 mb-5">
<Clock className="h-4 w-4 text-muted-foreground" />
Delivery Health
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow
icon={<Bell className="h-4 w-4" />}
label="Status"
value={getStatusLabel(data.enabled, data.status)}
/>
<ConfigRow icon={<Settings className="h-4 w-4" />} label="Enabled" value={data.enabled ? "Yes" : "No"} />
<ConfigRow
icon={<Clock className="h-4 w-4" />}
label="Last Checked"
value={data.lastChecked ? formatDateTime(data.lastChecked) : "Never"}
/>
</div>
</Card>
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
<AlertCircle className="h-4 w-4" />
Last Delivery Error
</CardTitle>
<p className="text-sm text-muted-foreground wrap-break-word">{data.lastError}</p>
</Card>
</div> </div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>

View file

@ -1,6 +1,18 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Bell, Plus, RotateCcw } from "lucide-react"; import { Bell, Plus, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DataTableSortHeader } from "~/client/components/data-table-sort-header";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot"; import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -8,20 +20,53 @@ import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
export function NotificationsPage() { type NotificationRow = {
const [searchQuery, setSearchQuery] = useState(""); id: number;
const [typeFilter, setTypeFilter] = useState(""); name: string;
const [statusFilter, setStatusFilter] = useState(""); type: "email" | "slack" | "discord" | "gotify" | "ntfy" | "pushover" | "telegram" | "custom" | "generic";
enabled: boolean;
status: "healthy" | "error" | "unknown";
};
const clearFilters = () => { const getNotificationStatus = (row: NotificationRow) => (row.enabled ? row.status : "disabled");
setSearchQuery(""); const getNotificationStatusVariant = (row: NotificationRow) => {
setTypeFilter(""); if (!row.enabled) return "neutral";
setStatusFilter(""); if (row.status === "healthy") return "success";
}; if (row.status === "error") return "error";
return "warning";
};
const notificationColumns: ColumnDef<NotificationRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => row.original.name,
},
{
accessorKey: "type",
header: ({ column }) => <DataTableSortHeader column={column} title="Type" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => row.original.type,
filterFn: (row, id, value) => row.getValue(id) === value,
},
{
accessorFn: getNotificationStatus,
id: "status",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
),
cell: ({ row }) => (
<StatusDot variant={getNotificationStatusVariant(row.original)} label={getNotificationStatus(row.original)} />
),
filterFn: (row, id, value) => row.getValue(id) === value,
},
];
export function NotificationsPage() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const navigate = useNavigate(); const navigate = useNavigate();
@ -29,15 +74,24 @@ export function NotificationsPage() {
...listNotificationDestinationsOptions(), ...listNotificationDestinationsOptions(),
}); });
const filteredNotifications = data.filter((notification) => { const table = useReactTable({
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase()); data,
const matchesType = !typeFilter || notification.type === typeFilter; columns: notificationColumns,
const matchesStatus = !statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled); state: { columnFilters, sorting },
return matchesSearch && matchesType && matchesStatus; onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
}); });
const rows = table.getRowModel().rows;
const hasFilters = columnFilters.length > 0;
const clearFilters = () => table.resetColumnFilters();
const hasNoNotifications = data.length === 0; const hasNoNotifications = data.length === 0;
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications; const hasNoFilteredNotifications = rows.length === 0 && !hasNoNotifications;
if (hasNoNotifications) { if (hasNoNotifications) {
return ( return (
@ -55,6 +109,10 @@ export function NotificationsPage() {
); );
} }
const search = (table.getColumn("name")?.getFilterValue() as string) ?? "";
const type = (table.getColumn("type")?.getFilterValue() as string) ?? "";
const status = (table.getColumn("status")?.getFilterValue() as string) ?? "";
return ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
@ -62,10 +120,10 @@ export function NotificationsPage() {
<Input <Input
className="w-full lg:w-45 min-w-45" className="w-full lg:w-45 min-w-45"
placeholder="Search…" placeholder="Search…"
value={searchQuery} value={search}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
/> />
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={type} onValueChange={(value) => table.getColumn("type")?.setFilterValue(value)}>
<SelectTrigger className="w-full lg:w-45 min-w-45"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All types" /> <SelectValue placeholder="All types" />
</SelectTrigger> </SelectTrigger>
@ -77,19 +135,22 @@ export function NotificationsPage() {
<SelectItem value="ntfy">Ntfy</SelectItem> <SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem> <SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem> <SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="generic">Generic</SelectItem>
<SelectItem value="custom">Custom</SelectItem> <SelectItem value="custom">Custom</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={status} onValueChange={(value) => table.getColumn("status")?.setFilterValue(value)}>
<SelectTrigger className="w-full lg:w-45 min-w-45"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="enabled">Enabled</SelectItem> <SelectItem value="healthy">Healthy</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem> <SelectItem value="disabled">Disabled</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || typeFilter || statusFilter) && ( {hasFilters && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2"> <Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Clear filters Clear filters
@ -104,11 +165,22 @@ export function NotificationsPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHead className="w-25 uppercase">Name</TableHead> <TableRow key={headerGroup.id}>
<TableHead className="uppercase text-left">Type</TableHead> {headerGroup.headers.map((header) => (
<TableHead className="uppercase text-center">Status</TableHead> <TableHead
</TableRow> key={header.id}
className={cn("uppercase", {
"w-25": header.column.id === "name",
"text-left": header.column.id === "type",
"text-center": header.column.id === "status",
})}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow className={cn({ hidden: !hasNoFilteredNotifications })}> <TableRow className={cn({ hidden: !hasNoFilteredNotifications })}>
@ -122,30 +194,38 @@ export function NotificationsPage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
{filteredNotifications.map((notification) => ( {rows.map((row) => (
<TableRow <TableRow
key={notification.id} key={row.original.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12" className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/notifications/${notification.id}` })} onClick={() => navigate({ to: `/notifications/${row.original.id}` })}
> >
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell> {row.getVisibleCells().map((cell) => (
<TableCell className="capitalize text-muted-foreground">{notification.type}</TableCell> <TableCell
<TableCell className="text-center"> key={cell.id}
<StatusDot className={cn({
variant={notification.enabled ? "success" : "neutral"} "font-medium text-strong-accent": cell.column.id === "name",
label={notification.enabled ? "Enabled" : "Disabled"} "capitalize text-muted-foreground": cell.column.id === "type",
/> "text-center": cell.column.id === "status",
</TableCell> })}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t"> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
<span> {hasNoFilteredNotifications ? (
<span className="text-strong-accent">{filteredNotifications.length}</span> destination "No destinations match filters."
{filteredNotifications.length !== 1 ? "s" : ""} ) : (
</span> <span>
<span className="text-strong-accent">{rows.length}</span> destination
{rows.length !== 1 ? "s" : ""}
</span>
)}
</div> </div>
</Card> </Card>
); );

View file

@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
azure: { backend: "azure" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const }, rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false }, sftp: {
backend: "sftp" as const,
compressionMode: "auto" as const,
port: 22,
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
}); });
export const CreateRepositoryForm = ({ export const CreateRepositoryForm = ({
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
</FormControl> </FormControl>
<div className="space-y-1"> <div className="space-y-1">
<FormLabel>Import existing repository</FormLabel> <FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription> <FormDescription>
Check this if the repository already exists at the specified location
</FormDescription>
</div> </div>
</FormItem> </FormItem>
)} )}
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
a custom password for the existing repository. account) or enter a custom password for the existing repository.
</FormDescription> </FormDescription>
</FormItem> </FormItem>

View file

@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
const cacert = useWatch({ control: form.control, name: "cacert" }); const cacert = useWatch({ control: form.control, name: "cacert" });
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" }); const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" }); const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
const backend = useWatch({ control: form.control, name: "backend" });
return ( return (
<Collapsible> <Collapsible>
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl> </FormControl>
<div className="space-y-1"> <div className="space-y-1">
<FormLabel>Enable upload speed limit</FormLabel> <FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription> <FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
</div> </div>
</FormItem> </FormItem>
)} )}
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
placeholder="10" placeholder="10"
className="pr-12" className="pr-12"
{...field} {...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)} onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/> />
<div className="absolute inset-y-0 right-0 flex items-center pr-3"> <div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl> </FormControl>
<div className="space-y-1"> <div className="space-y-1">
<FormLabel>Enable download speed limit</FormLabel> <FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription> <FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
</div> </div>
</FormItem> </FormItem>
)} )}
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
disabled={!downloadLimitEnabled} disabled={!downloadLimitEnabled}
className="pr-12" className="pr-12"
{...field} {...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)} onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/> />
<div className="absolute inset-y-0 right-0 flex items-center pr-3"> <div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}> <TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs"> <p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip This option is disabled because a CA certificate is provided. Remove the CA
TLS validation instead. certificate to skip TLS validation instead.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel> <FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription> <FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is Disable TLS certificate verification for HTTPS connections with self-signed
insecure and should only be used for testing. certificates. This is insecure and should only be used for testing.
</FormDescription> </FormDescription>
</div> </div>
</FormItem> </FormItem>
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<Textarea <Textarea
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"} placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={6} rows={6}
disabled={insecureTls} disabled={insecureTls}
{...field} {...field}
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}> <TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs"> <p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate CA certificate is disabled because TLS validation is being skipped. Uncheck
Verification" to provide a custom CA certificate. "Skip TLS Certificate Verification" to provide a custom CA certificate.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{backend === "sftp" && (
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );

View file

@ -1,7 +1,18 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Database, Plus, RotateCcw } from "lucide-react"; import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DataTableSortHeader } from "~/client/components/data-table-sort-header";
import { RepositoryIcon } from "~/client/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
@ -12,17 +23,73 @@ import { cn } from "~/client/lib/utils";
import { StatusDot } from "~/client/components/status-dot"; import { StatusDot } from "~/client/components/status-dot";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import type { RepositoryBackend } from "@zerobyte/core/restic";
type RepositoryRow = {
id: string;
shortId: string;
name: string;
type: RepositoryBackend;
status: string | null;
compressionMode?: string | null;
};
const repositoryColumns: ColumnDef<RepositoryRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" sortDirection={column.getIsSorted()} />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span>{row.original.name}</span>
</div>
),
},
{
accessorKey: "type",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Backend" sortDirection={column.getIsSorted()} />
),
cell: ({ row }) => (
<span className="flex items-center gap-2 text-muted-foreground">
<RepositoryIcon backend={row.original.type} />
{row.original.type}
</span>
),
filterFn: (row, id, value) => row.getValue(id) === value,
},
{
accessorFn: (row) => row.compressionMode || "off",
id: "compressionMode",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Compression" sortDirection={column.getIsSorted()} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{row.original.compressionMode || "off"}
</span>
),
sortingFn: "alphanumeric",
},
{
accessorFn: (row) => row.status || "unknown",
id: "status",
header: ({ column }) => (
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
),
cell: ({ row }) => (
<StatusDot
variant={row.original.status === "healthy" ? "success" : row.original.status === "error" ? "error" : "warning"}
label={row.original.status || "unknown"}
/>
),
sortingFn: "alphanumeric",
filterFn: (row, id, value) => row.getValue(id) === value,
},
];
export function RepositoriesPage() { export function RepositoriesPage() {
const [searchQuery, setSearchQuery] = useState(""); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [statusFilter, setStatusFilter] = useState(""); const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [backendFilter, setBackendFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate(); const navigate = useNavigate();
@ -30,15 +97,23 @@ export function RepositoriesPage() {
...listRepositoriesOptions(), ...listRepositoriesOptions(),
}); });
const filteredRepositories = data.filter((repository) => { const table = useReactTable({
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase()); data,
const matchesStatus = !statusFilter || repository.status === statusFilter; columns: repositoryColumns,
const matchesBackend = !backendFilter || repository.type === backendFilter; state: { columnFilters, sorting },
return matchesSearch && matchesStatus && matchesBackend; onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
}); });
const rows = table.getRowModel().rows;
const hasFilters = columnFilters.length > 0;
const clearFilters = () => table.resetColumnFilters();
const hasNoRepositories = data.length === 0; const hasNoRepositories = data.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories; const hasNoFilteredRepositories = rows.length === 0 && !hasNoRepositories;
if (hasNoRepositories) { if (hasNoRepositories) {
return ( return (
@ -56,6 +131,10 @@ export function RepositoriesPage() {
); );
} }
const search = (table.getColumn("name")?.getFilterValue() as string) ?? "";
const type = (table.getColumn("type")?.getFilterValue() as string) ?? "";
const status = (table.getColumn("status")?.getFilterValue() as string) ?? "";
return ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
@ -63,10 +142,10 @@ export function RepositoriesPage() {
<Input <Input
className="w-full lg:w-45 min-w-45" className="w-full lg:w-45 min-w-45"
placeholder="Search…" placeholder="Search…"
value={searchQuery} value={search}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={status} onValueChange={(value) => table.getColumn("status")?.setFilterValue(value)}>
<SelectTrigger className="w-full lg:w-45 min-w-45"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
@ -76,7 +155,7 @@ export function RepositoriesPage() {
<SelectItem value="unknown">Unknown</SelectItem> <SelectItem value="unknown">Unknown</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={type} onValueChange={(value) => table.getColumn("type")?.setFilterValue(value)}>
<SelectTrigger className="w-full lg:w-45 min-w-45"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
@ -87,7 +166,7 @@ export function RepositoriesPage() {
<SelectItem value="gcs">Google Cloud Storage</SelectItem> <SelectItem value="gcs">Google Cloud Storage</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || statusFilter || backendFilter) && ( {hasFilters && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2"> <Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Clear filters Clear filters
@ -102,12 +181,23 @@ export function RepositoriesPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHead className="w-25 uppercase">Name</TableHead> <TableRow key={headerGroup.id}>
<TableHead className="uppercase text-left">Backend</TableHead> {headerGroup.headers.map((header) => (
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead> <TableHead
<TableHead className="uppercase text-center">Status</TableHead> key={header.id}
</TableRow> className={cn("uppercase", {
"w-25": header.column.id === "name",
"text-left": header.column.id === "type",
"hidden sm:table-cell": header.column.id === "compressionMode",
"text-center": header.column.id === "status",
})}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow className={cn({ hidden: !hasNoFilteredRepositories })}> <TableRow className={cn({ hidden: !hasNoFilteredRepositories })}>
@ -121,38 +211,24 @@ export function RepositoriesPage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
{filteredRepositories.map((repository) => ( {rows.map((row) => (
<TableRow <TableRow
key={repository.id} key={row.original.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12" className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })} onClick={() => navigate({ to: `/repositories/${row.original.shortId}` })}
> >
<TableCell className="font-medium text-strong-accent"> {row.getVisibleCells().map((cell) => (
<div className="flex items-center gap-2"> <TableCell
<span>{repository.name}</span> key={cell.id}
</div> className={cn({
</TableCell> "font-medium text-strong-accent": cell.column.id === "name",
<TableCell> "hidden sm:table-cell": cell.column.id === "compressionMode",
<span className="flex items-center gap-2 text-muted-foreground"> "text-center": cell.column.id === "status",
<RepositoryIcon backend={repository.type} /> })}
{repository.type} >
</span> {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell"> ))}
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot
variant={
repository.status === "healthy" ? "success" : repository.status === "error" ? "error" : "warning"
}
label={
repository.status ? repository.status[0].toUpperCase() + repository.status.slice(1) : "Unknown"
}
/>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -163,8 +239,8 @@ export function RepositoriesPage() {
"No repositories match filters." "No repositories match filters."
) : ( ) : (
<span> <span>
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor <span className="text-strong-accent">{rows.length}</span> repositor
{filteredRepositories.length === 1 ? "y" : "ies"} {rows.length === 1 ? "y" : "ies"}
</span> </span>
)} )}
</div> </div>

View file

@ -15,7 +15,7 @@ import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router"; import { Link, useParams } from "@tanstack/react-router";
import { getVolumeMountPath } from "~/client/lib/volume-path"; import { getVolumeMountPath } from "~/client/lib/volume-path";
export const SnapshotError = () => { const SnapshotError = () => {
const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" }); const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" });
return ( return (

View file

@ -0,0 +1,322 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { getIsSecureContext } from "~/client/functions/is-secure-context";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type PasskeyEntry = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string;
};
export function PasskeysSection() {
const { formatDateTime } = useTimeFormat();
const isSecureContext = getIsSecureContext();
const { data: passkeys, isPending } = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) throw error;
return data;
},
});
const [deletePasskeyOpen, setDeletePasskeyOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const addPasskeyMutation = useMutation({
mutationFn: async (name: string | undefined) => {
const { error } = await authClient.passkey.addPasskey({ name });
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey added");
setAddDialogOpen(false);
setNewPasskeyName("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
},
});
const renamePasskeyMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id, name },
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.passkey.deletePasskey({ id });
if (error) throw error;
},
onMutate: () => {
setDeletePasskeyOpen(false);
},
onSuccess: () => {
toast.success("Passkey deleted");
setDeleteTarget(null);
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
},
});
const handleAddPasskey = (e: React.ChangeEvent) => {
e.preventDefault();
if (!isSecureContext) return;
const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name);
};
const handleRename = (e: React.ChangeEvent) => {
e.preventDefault();
if (!renameTarget) return;
const name = renameValue.trim();
if (!name) {
toast.error("Name is required");
return;
}
renamePasskeyMutation.mutate({ id: renameTarget.id, name });
};
const handleDelete = () => {
if (!deleteTarget) return;
deletePasskeyMutation.mutate(deleteTarget.id);
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Passkeys
</CardTitle>
<CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can
add more than one.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl">
Passkeys use your device's biometrics or screen lock instead of a password. They are
phishing-resistant and cannot be reused across sites.
</p>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button onClick={() => setAddDialogOpen(true)} disabled={!isSecureContext}>
<Plus className="h-4 w-4 mr-2" />
Add passkey
</Button>
</span>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: isSecureContext })}>
<p>Passkeys can only be added over HTTPS or from localhost.</p>
</TooltipContent>
</Tooltip>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
<p className={cn("text-sm text-muted-foreground", { hidden: passkeys && passkeys.length > 0 })}>
No passkeys yet. Add one to enable passwordless sign-in.
</p>
<ul
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
hidden: passkeys?.length === 0,
})}
>
{passkeys?.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(p.createdAt))}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setRenameTarget(p);
setRenameValue(p.name ?? "");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setDeleteTarget(p);
setDeletePasskeyOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</CardContent>
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setNewPasskeyName("");
}}
>
<DialogContent>
<form onSubmit={handleAddPasskey}>
<DialogHeader>
<DialogTitle>Add a passkey</DialogTitle>
<DialogDescription>
Give this passkey a name so you can recognize it later (e.g. "MacBook Touch ID").
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-name">Name (optional)</Label>
<Input
id="passkey-name"
value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={addPasskeyMutation.isPending} disabled={!isSecureContext}>
<Fingerprint className="h-4 w-4 mr-2" />
Add passkey
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null);
setRenameValue("");
}
}}
>
<DialogContent>
<form onSubmit={handleRename}>
<DialogHeader>
<DialogTitle>Rename passkey</DialogTitle>
<DialogDescription>Choose a name to recognize this passkey.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-rename">Name</Label>
<Input
id="passkey-rename"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button type="submit" loading={renamePasskeyMutation.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deletePasskeyOpen} onOpenChange={setDeletePasskeyOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You
won't be able to use it to sign in anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePasskeyMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={deletePasskeyMutation.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,11 +1,21 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Download, Fingerprint, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react"; import {
AlertTriangle,
Download,
Fingerprint,
KeyRound,
User,
X,
Settings as SettingsIcon,
Building2,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen"; import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -29,12 +39,18 @@ import {
useTimeFormat, useTimeFormat,
} from "~/client/lib/datetime"; } from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger"; import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context"; import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section"; import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section"; import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section"; import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils";
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = { type Props = {
appContext: AppContext; appContext: AppContext;
@ -49,6 +65,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState(""); const [downloadPassword, setDownloadPassword] = useState("");
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false); const [isChangingPassword, setIsChangingPassword] = useState(false);
const { dateFormat, timeFormat } = useRootLoaderData(); const { dateFormat, timeFormat } = useRootLoaderData();
@ -59,6 +76,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
const { activeMember, activeOrganization } = useOrganizationContext(); const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin"; const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const { formatDateTime } = useTimeFormat(); const { formatDateTime } = useTimeFormat();
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
const handleLogout = async () => { const handleLogout = async () => {
await authClient.signOut({ await authClient.signOut({
@ -85,15 +103,18 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(url); window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Restic password file downloaded successfully"); toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false); setDownloadDialogOpen(false);
setDownloadPassword(""); setDownloadPassword("");
setDownloadBlockedMessage(null);
}, },
onError: (error) => { onError: (error) => {
const message = parseError(error)?.message;
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download Restic password", { toast.error("Failed to download Restic password", {
description: error.message, description: message,
}); });
}, },
}); });
@ -145,6 +166,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
return; return;
} }
setDownloadBlockedMessage(null);
downloadResticPassword.mutate({ downloadResticPassword.mutate({
body: { body: {
password: downloadPassword, password: downloadPassword,
@ -213,11 +235,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">Username</Label>
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" /> <Input
id="username"
value={appContext.user?.username}
disabled
className="max-w-md"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" /> <Input
id="email"
type="email"
value={appContext.user?.email}
disabled
className="max-w-md"
/>
</div> </div>
</CardContent> </CardContent>
@ -237,7 +270,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="date-format">Date format</Label> <Label htmlFor="date-format">Date format</Label>
<Select <Select
value={dateFormat} value={dateFormat}
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)} onValueChange={(value) =>
void handleDateFormatChange(value as DateFormatPreference)
}
> >
<SelectTrigger id="date-format"> <SelectTrigger id="date-format">
<SelectValue /> <SelectValue />
@ -255,7 +290,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="time-format">Time format</Label> <Label htmlFor="time-format">Time format</Label>
<Select <Select
value={timeFormat} value={timeFormat}
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)} onValueChange={(value) =>
void handleTimeFormatChange(value as TimeFormatPreference)
}
> >
<SelectTrigger id="time-format"> <SelectTrigger id="time-format">
<SelectValue /> <SelectValue />
@ -270,18 +307,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</Select> </Select>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p> <p className="text-sm text-muted-foreground">
Preview: {formatDateTime(new Date())}
</p>
</div> </div>
</CardContent> </CardContent>
<div className="border-t border-border/50 bg-card-header p-6"> <div
className={cn("border-t border-border/50 bg-card-header p-6", {
hidden: !hasCredentialPassword,
})}
>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" /> <KeyRound className="size-5" />
Change Password Change Password
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription> <CardDescription className="mt-1.5">
Update your password to keep your account secure
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
<form onSubmit={handleChangePassword} className="space-y-4"> <form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label> <Label htmlFor="current-password">Current Password</Label>
@ -305,7 +350,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
required required
minLength={8} minLength={8}
/> />
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p> <p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label> <Label htmlFor="confirm-password">Confirm New Password</Label>
@ -331,13 +378,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Download className="size-5" /> <Download className="size-5" />
Backup Recovery Key Backup Recovery Key
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription> <CardDescription className="mt-1.5">
Download your recovery key for Restic backups
</CardDescription>
</div> </div>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">
This file contains the encryption password used by Restic to secure your backups. Store it in a safe This file contains the encryption password used by Restic to secure your backups.
place (like a password manager or encrypted storage). If you lose access to this server, you'll need Store it in a safe place (like a password manager or encrypted storage). If you lose
this file to recover your backup data. access to this server, you'll need this file to recover your backup data.
</p> </p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}> <Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
@ -352,11 +401,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<DialogHeader> <DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle> <DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription> <DialogDescription>
For security reasons, please enter your account password to download the recovery key file. {!hasCredentialPassword
? "A local credential password is required before this recovery key can be downloaded."
: "For security reasons, please enter your account password to download the recovery key file."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <Alert
variant="warning"
className={cn({
hidden: hasCredentialPassword && !downloadBlockedMessage,
})}
>
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{downloadBlockedMessage ??
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
<Label htmlFor="download-password">Your Password</Label> <Label htmlFor="download-password">Your Password</Label>
<Input <Input
id="download-password" id="download-password"
@ -380,7 +444,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<X className="h-4 w-4 mr-2" /> <X className="h-4 w-4 mr-2" />
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={downloadResticPassword.isPending}> <Button
type="submit"
loading={downloadResticPassword.isPending}
className={cn({ hidden: !hasCredentialPassword })}
>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Download Download
</Button> </Button>
@ -391,6 +459,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</CardContent> </CardContent>
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} /> <TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
</Card> </Card>
</TabsContent> </TabsContent>
@ -402,7 +472,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Fingerprint className="size-5" /> <Fingerprint className="size-5" />
Organization Details Organization Details
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Reference details for the active organization</CardDescription> <CardDescription className="mt-1.5">
Reference details for the active organization
</CardDescription>
</div> </div>
<CardContent className="p-6 space-y-2"> <CardContent className="p-6 space-y-2">
<Label htmlFor="organization-id">Organization ID</Label> <Label htmlFor="organization-id">Organization ID</Label>
@ -421,7 +493,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Building2 className="size-5" /> <Building2 className="size-5" />
Members Members
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription> <CardDescription className="mt-1.5">
Manage organization members and roles
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<OrgMembersSection initialMembers={initialMembers} /> <OrgMembersSection initialMembers={initialMembers} />
@ -434,10 +508,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<SettingsIcon className="size-5" /> <SettingsIcon className="size-5" />
Single Sign-On Single Sign-On
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription> <CardDescription className="mt-1.5">
Configure OIDC provider settings
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} /> <SsoSettingsSection
initialSettings={initialSsoSettings}
initialOrigin={initialOrigin}
/>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View file

@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type SsoProvider = {
providerId: string;
};
type SsoLoginButtonsProps = {
providers: SsoProvider[];
};
export function SsoLoginButtons({ providers }: SsoLoginButtonsProps) {
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
return (
<>
{providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</>
);
}

View file

@ -1,58 +0,0 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function SsoLoginSection() {
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (ssoProviders.providers.length === 0) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View file

@ -25,7 +25,7 @@ import {
smbConfigSchema, smbConfigSchema,
volumeConfigSchema, volumeConfigSchema,
webdavConfigSchema, webdavConfigSchema,
} from "~/schemas/volumes"; } from "@zerobyte/contracts/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
@ -66,10 +66,10 @@ type Props = {
const defaultValuesForType = { const defaultValuesForType = {
directory: { backend: "directory" as const, path: "/" }, directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" }, webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
rclone: { backend: "rclone" as const, path: "/" }, rclone: { backend: "rclone" as const, path: "/" },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false }, sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
}; };
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone"> <SelectItem
disabled={!capabilities.rclone || !capabilities.sysAdmin}
value="rclone"
>
rclone rclone
</SelectItem> </SelectItem>
</div> </div>
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}> <TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p> <p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent> </TooltipContent>
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}> <TooltipContent
className={cn({
hidden: !capabilities.sysAdmin || capabilities.rclone,
})}
>
<p>Setup rclone to use this backend</p> <p>Setup rclone to use this backend</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

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