diff --git a/.dockerignore b/.dockerignore index ed763eb8..a65c78aa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ !bun.lock !package.json !.gitignore +!bunfig.toml !**/package.json !**/bun.lock @@ -23,3 +24,4 @@ node_modules/** dist/** .output/** +app/test/integration/artifacts/** diff --git a/.env.example b/.env.example index 321cc1e8..8d2c6210 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache ZEROBYTE_REPOSITORIES_DIR=./data/repositories ZEROBYTE_VOLUMES_DIR=./data/volumes APP_SECRET= -BASE_URL=http://localhost:300 +BASE_URL=http://*.localhost TRUST_PROXY=false PORT=3000 diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 586d3b5c..86ba31e3 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -17,9 +17,9 @@ runs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 name: Install Bun with: - bun-version: "1.3.13" + bun-version: "1.3.14" - - uses: voidzero-dev/setup-vp@8ecb39174989ce55af90f45cf55b02738599831d # v1 + - uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1 with: node-version: '24' cache: true diff --git a/.github/renovate.json b/.github/renovate.json index 364366de..c43afbb9 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,8 +2,14 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended", "helpers:pinGitHubActionDigests"], "enabledManagers": ["bun", "github-actions"], - "minimumReleaseAge": "1 day", + "minimumReleaseAge": "3 days", + "rangeStrategy": "bump", "packageRules": [ + { + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["digest", "pinDigest"], + "minimumReleaseAge": null + }, { "matchManagers": ["bun"], "matchUpdateTypes": ["minor", "patch"], diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 93491e05..41e2e16d 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -13,6 +13,7 @@ on: jobs: deploy: name: Deploy docs + if: github.event.repository.default_branch == github.ref_name runs-on: ubuntu-latest timeout-minutes: 10 @@ -27,7 +28,7 @@ jobs: run: bun run build - name: Build and deploy docs - uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 88521ce4..31789fd0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: - name: Cache Playwright Browsers id: playwright-cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} @@ -96,7 +96,7 @@ jobs: run: | 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() with: name: playwright-report diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..573fdbea --- /dev/null +++ b/.github/workflows/integration.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 086596c3..69267179 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,20 +20,20 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Log in to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: cloud endpoint: "meienberger/runtipi-builder" install: true - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -41,14 +41,14 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: ghcr.io/${{ github.repository_owner }}/zerobyte tags: | type=raw,value=nightly - name: Push images to GitHub Container Registry - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . target: production diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c7c0d53..637f367d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,10 +43,13 @@ jobs: e2e-tests: uses: ./.github/workflows/e2e.yml + integration-tests: + uses: ./.github/workflows/integration.yml + build-images: environment: release timeout-minutes: 15 - needs: [determine-release-type, checks, e2e-tests] + needs: [determine-release-type, checks, e2e-tests, integration-tests] runs-on: ubuntu-latest permissions: contents: read @@ -60,56 +63,28 @@ jobs: ref: ${{ github.ref }} - name: Log in to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: cloud endpoint: "meienberger/runtipi-builder" install: true - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.repository_owner }} 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 id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: ghcr.io/${{ github.repository_owner }}/zerobyte tags: | @@ -121,7 +96,7 @@ jobs: latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} - name: Push images to GitHub Container Registry - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . target: production @@ -153,7 +128,6 @@ jobs: name: ${{ needs.determine-release-type.outputs.tagname }} draft: false prerelease: true - files: cli/runtipi-cli-* request-docs-version-update: uses: ./.github/workflows/request-docs-version-update.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5f040bfa..6b21a58b 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,6 +36,6 @@ jobs: retention-days: 5 - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index d3b47d61..856ba616 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ notes.md smb-password.txt cache.db -data/ +/data .env* !.env.example @@ -34,8 +34,11 @@ node_modules/ playwright/.auth playwright/temp +playwright/data .idea/ +.deepsec/ +.codex/ # OpenAPI error logs openapi-ts-error-*.log diff --git a/AGENTS.md b/AGENTS.md index a1c72922..62ac15c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ - 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 -- 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 - 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). +### 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 ```bash diff --git a/Dockerfile b/Dockerfile index e1834cf3..c46a5aed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM oven/bun:1.3.13-alpine@sha256:4de475389889577f346c636f956b42a5c31501b654664e9ae5726f94d7bb5349 AS base +FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base ARG RESTIC_VERSION="0.18.1" -ARG RCLONE_VERSION="1.73.5" -ARG SHOUTRRR_VERSION="0.14.3" +ARG RCLONE_VERSION="1.74.2" +ARG SHOUTRRR_VERSION="0.15.1" ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \ @@ -10,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ RUN apk update --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", "--"] @@ -44,6 +44,15 @@ RUN bzip2 -d restic.bz2 && chmod +x restic RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone 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 # ------------------------------ diff --git a/README.md b/README.md index 0b95c2dd..c111f8c4 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ [![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH) > [!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. Please open issues for bugs 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.

Buy Me A Coffee

-## 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. @@ -38,10 +38,10 @@ It contains up-to-date setup guides, configuration reference, and usage document ### Features --   **Automated backups** with encryption, compression and retention policies powered by Restic --   **Flexible scheduling** For automated backup jobs with fine-grained retention policies --   **End-to-end encryption** ensuring your data is always protected --   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories +- **Automated backups** with encryption, compression, and retention policies, powered by Restic +- **Flexible scheduling** for automated backup jobs with fine-grained retention policies +- **End-to-end encryption** will ensure your data is always protected +- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories ## Installation @@ -50,7 +50,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.35 + image: ghcr.io/nicotsx/zerobyte:v0.37 container_name: zerobyte restart: unless-stopped cap_add: @@ -69,10 +69,10 @@ services: ``` > [!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] -> 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] > **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: @@ -97,24 +97,46 @@ Once the container is running, you can access the web interface at `http:// { }; if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); + await setAuthParams(opts); } if (opts.requestValidator) { @@ -76,171 +73,154 @@ export const createClient = (config: Config = {}): Client => { }; const request: Client['request'] = async (options) => { - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; - let request = new Request(url, requestInit); - - 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; + let request: Request | undefined; + let response: Response | undefined; try { - response = await _fetch(request); - } catch (error) { - // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error; + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...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) { - 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) { - throw finalError; + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } } - // Return error response - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response: undefined as any, - }; - } + const result = { + request, + response, + }; - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; - const result = { - request, - response, - }; + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + 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) { - 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; + let data: any; switch (parseAs) { case 'arrayBuffer': case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; case 'formData': - emptyData = new FormData(); + case 'text': + data = await response[parseAs](); 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': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + return opts.responseStyle === 'data' - ? emptyData + ? data : { - data: emptyData, + data, ...result, }; } - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'text': - data = await response[parseAs](); - 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, - }; + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop } - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } + throw jsonError ?? textError; + } catch (error) { + let finalError = error; - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); } } - return opts.responseStyle === 'data' - ? data + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined : { - data, - ...result, + error: finalError, + 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) => (options: RequestOptions) => @@ -251,7 +231,6 @@ export const createClient = (config: Config = {}): Client => { return createSseClient({ ...opts, body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, method, onRequest: async (url, init) => { let request = new Request(url, init); diff --git a/app/client/api-client/client/types.gen.ts b/app/client/api-client/client/types.gen.ts index 7d2edad9..e1fe35f7 100644 --- a/app/client/api-client/client/types.gen.ts +++ b/app/client/api-client/client/types.gen.ts @@ -94,6 +94,7 @@ export interface ResolvedRequestOptions< ThrowOnError extends boolean = boolean, Url extends string = string, > extends RequestOptions { + headers: Headers; serializedBody?: string; } @@ -127,8 +128,10 @@ export type RequestResult< error: TError extends Record ? TError[keyof TError] : TError; } ) & { - request: Request; - response: Response; + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; } >; diff --git a/app/client/api-client/client/utils.gen.ts b/app/client/api-client/client/utils.gen.ts index 0cbe3272..26ea50e2 100644 --- a/app/client/api-client/client/utils.gen.ts +++ b/app/client/api-client/client/utils.gen.ts @@ -119,14 +119,12 @@ const checkForExistence = ( return false; }; -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { +export async function setAuthParams( + options: Pick & { headers: Headers; - }) => { - for (const auth of security) { + }, +): Promise { + for (const auth of options.security ?? []) { if (checkForExistence(options, auth.name)) { continue; } @@ -155,7 +153,7 @@ export const setAuthParams = async ({ break; } } -}; +} export const buildUrl: Client['buildUrl'] = (options) => getUrl({ @@ -219,8 +217,10 @@ export const mergeHeaders = ( type ErrInterceptor = ( error: Err, - response: Res, - request: Req, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, options: Options, ) => Err | Promise; diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index ddf355f3..8aa1adf3 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -337,14 +337,7 @@ export type ListVolumesResponses = { 200: Array<{ id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -359,6 +352,7 @@ export type ListVolumesResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -392,7 +386,15 @@ export type ListVolumesResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }>; }; @@ -416,6 +418,7 @@ export type CreateVolumeData = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -449,6 +452,7 @@ export type CreateVolumeData = { readOnly?: boolean; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; }; }; path?: never; @@ -463,14 +467,7 @@ export type CreateVolumeResponses = { 201: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -485,6 +482,7 @@ export type CreateVolumeResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -518,7 +516,15 @@ export type CreateVolumeResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; }; @@ -541,6 +547,7 @@ export type TestConnectionData = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -574,6 +581,7 @@ export type TestConnectionData = { readOnly?: boolean; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; }; }; path?: never; @@ -637,14 +645,7 @@ export type GetVolumeResponses = { volume: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -659,6 +660,7 @@ export type GetVolumeResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -692,13 +694,21 @@ export type GetVolumeResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; statfs: { - total: number; - used: number; - free: number; + total?: number; + used?: number; + free?: number; }; }; }; @@ -723,6 +733,7 @@ export type UpdateVolumeData = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -756,6 +767,7 @@ export type UpdateVolumeData = { readOnly?: boolean; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; }; }; path: { @@ -779,14 +791,7 @@ export type UpdateVolumeResponses = { 200: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -801,6 +806,7 @@ export type UpdateVolumeResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -834,7 +840,15 @@ export type UpdateVolumeResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; }; @@ -855,8 +869,8 @@ export type MountVolumeResponses = { * Volume mounted successfully */ 200: { - error?: string; status: 'mounted' | 'unmounted' | 'error'; + error?: string; }; }; @@ -876,8 +890,8 @@ export type UnmountVolumeResponses = { * Volume unmounted successfully */ 200: { - error?: string; status: 'mounted' | 'unmounted' | 'error'; + error?: string; }; }; @@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = { * Volume health check result */ 200: { - error?: string; status: 'mounted' | 'unmounted' | 'error'; + error?: string; }; }; @@ -932,7 +946,7 @@ export type ListFilesResponses = { files: Array<{ name: string; path: string; - type: 'file' | 'directory'; + type: 'directory' | 'file'; size?: number; modifiedAt?: number; }>; @@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = { directories: Array<{ name: string; path: string; - type: 'file' | 'directory'; - size?: number; + type: 'directory'; + size?: unknown; modifiedAt?: number; }>; path: string; @@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -1319,6 +1334,7 @@ export type CreateRepositoryData = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -2248,6 +2267,7 @@ export type RestoreSnapshotData = { excludeXattr?: Array; delete?: boolean; targetPath?: string; + targetAgentId?: string; overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never'; }; path: { @@ -2259,13 +2279,11 @@ export type RestoreSnapshotData = { export type RestoreSnapshotResponses = { /** - * Snapshot restored successfully + * Snapshot restore started */ - 200: { - success: boolean; - message: string; - filesRestored: number; - filesSkipped: number; + 202: { + restoreId: string; + status: 'started'; }; }; @@ -2434,6 +2452,18 @@ export type ListBackupSchedulesResponses = { includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; + backupWebhooks: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries: number; retryDelay: number; lastBackupAt: number | null; @@ -2445,14 +2475,7 @@ export type ListBackupSchedulesResponses = { volume: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -2467,6 +2490,7 @@ export type ListBackupSchedulesResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -2500,7 +2524,15 @@ export type ListBackupSchedulesResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; repository: { @@ -2652,6 +2684,7 @@ export type ListBackupSchedulesResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -2712,6 +2745,18 @@ export type CreateBackupScheduleData = { oneFileSystem?: boolean; tags?: Array; customResticParams?: Array; + backupWebhooks?: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries?: number; retryDelay?: number; }; @@ -2747,6 +2792,18 @@ export type CreateBackupScheduleResponses = { includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; + backupWebhooks: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries: number; retryDelay: number; lastBackupAt: number | null; @@ -2816,6 +2873,18 @@ export type GetBackupScheduleResponses = { includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; + backupWebhooks: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries: number; retryDelay: number; lastBackupAt: number | null; @@ -2827,14 +2896,7 @@ export type GetBackupScheduleResponses = { volume: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -2849,6 +2911,7 @@ export type GetBackupScheduleResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -2882,7 +2945,15 @@ export type GetBackupScheduleResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; repository: { @@ -3034,6 +3105,7 @@ export type GetBackupScheduleResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -3093,6 +3165,18 @@ export type UpdateBackupScheduleData = { oneFileSystem?: boolean; tags?: Array; customResticParams?: Array; + backupWebhooks?: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries?: number; retryDelay?: number; }; @@ -3130,6 +3214,18 @@ export type UpdateBackupScheduleResponses = { includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; + backupWebhooks: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries: number; retryDelay: number; lastBackupAt: number | null; @@ -3179,6 +3275,18 @@ export type GetBackupScheduleForVolumeResponses = { includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; + backupWebhooks: { + pre: { + url: string; + headers?: Array; + body?: string; + } | null; + post: { + url: string; + headers?: Array; + body?: string; + } | null; + } | null; maxRetries: number; retryDelay: number; lastBackupAt: number | null; @@ -3190,14 +3298,7 @@ export type GetBackupScheduleForVolumeResponses = { volume: { id: number; shortId: string; - provisioningId: string | null; name: string; - type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp'; - status: 'mounted' | 'unmounted' | 'error'; - lastError: string | null; - createdAt: number; - updatedAt: number; - lastHealthCheck: number; config: { backend: 'nfs'; server: string; @@ -3212,6 +3313,7 @@ export type GetBackupScheduleForVolumeResponses = { username?: string; password?: string; guest?: boolean; + mapToContainerUidGid?: boolean; vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto'; domain?: string; port?: string | number; @@ -3245,7 +3347,15 @@ export type GetBackupScheduleForVolumeResponses = { readOnly?: boolean; skipHostKeyCheck?: boolean; 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; }; repository: { @@ -3397,6 +3507,7 @@ export type GetBackupScheduleForVolumeResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -3526,6 +3637,9 @@ export type GetScheduleNotificationsResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; @@ -3627,6 +3741,9 @@ export type UpdateScheduleNotificationsResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; @@ -3865,6 +3982,7 @@ export type GetScheduleMirrorsResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -4077,6 +4195,7 @@ export type UpdateScheduleMirrorsResponses = { privateKey: string; skipHostKeyCheck?: boolean; knownHosts?: string; + allowLegacySshRsa?: boolean; isExistingRepository?: boolean; customPassword?: string; cacert?: string; @@ -4258,6 +4377,9 @@ export type ListNotificationDestinationsResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; @@ -4401,6 +4523,9 @@ export type CreateNotificationDestinationResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; @@ -4519,6 +4644,9 @@ export type GetNotificationDestinationResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; @@ -4672,6 +4800,9 @@ export type UpdateNotificationDestinationResponses = { id: number; name: string; enabled: boolean; + status: 'healthy' | 'error' | 'unknown'; + lastChecked: number | null; + lastError: string | null; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; config: { type: 'email'; diff --git a/app/client/components/data-table-sort-header.tsx b/app/client/components/data-table-sort-header.tsx new file mode 100644 index 00000000..0a888e49 --- /dev/null +++ b/app/client/components/data-table-sort-header.tsx @@ -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({ + column, + title, + sortDirection, + center = false, +}: { + column: Column; + title: string; + sortDirection: false | "asc" | "desc"; + center?: boolean; +}) { + const icon = + sortDirection === "desc" ? ( + + ) : sortDirection === "asc" ? ( + + ) : ( + + ); + const iconVisibility = sortDirection ? "" : "lg:invisible lg:group-hover/sort:visible"; + + if (center) { + return ( + + ); + } + + return ( + + ); +} diff --git a/app/client/components/dev-panel-listener.tsx b/app/client/components/dev-panel-listener.tsx index d14d3e67..a8d50a82 100644 --- a/app/client/components/dev-panel-listener.tsx +++ b/app/client/components/dev-panel-listener.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; 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 { DevPanel } from "./dev-panel"; @@ -10,7 +10,12 @@ export function DevPanelListener() { ...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) { return null; diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index ff925f42..2271b016 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -64,7 +64,7 @@ export function Layout({ loaderData }: Props) { { -

{label}

+

{label}

); diff --git a/app/client/components/ui/input-otp.tsx b/app/client/components/ui/input-otp.tsx index 11575d92..1b31c06a 100644 --- a/app/client/components/ui/input-otp.tsx +++ b/app/client/components/ui/input-otp.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { OTPInput, OTPInputContext } from "input-otp"; -import { MinusIcon } from "lucide-react"; import { cn } from "~/client/lib/utils"; @@ -55,12 +54,8 @@ function InputOTPSlot({ ); } -function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( -
- -
- ); +function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) { + return
; } export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/app/client/components/ui/sonner.tsx b/app/client/components/ui/sonner.tsx index abcd1c27..d1119025 100644 --- a/app/client/components/ui/sonner.tsx +++ b/app/client/components/ui/sonner.tsx @@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "dark" } = useTheme(); + const { theme } = useTheme(); return ( { + 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 ( +
+ + +

This is a preview of the HTTP request that will be sent.

+
+ ); +}; diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index 4898643c..3a31dda5 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -28,6 +28,7 @@ const invalidatingEvents = new Set([ "backup:completed", "volume:updated", "volume:status_changed", + "notification:updated", "mirror:completed", "doctor:started", "doctor:completed", diff --git a/app/client/lib/auth-client.ts b/app/client/lib/auth-client.ts index aa7d031c..672cb8ae 100644 --- a/app/client/lib/auth-client.ts +++ b/app/client/lib/auth-client.ts @@ -7,6 +7,7 @@ import { inferAdditionalFields, } from "better-auth/client/plugins"; import { ssoClient } from "@better-auth/sso/client"; +import { passkeyClient } from "@better-auth/passkey/client"; import type { auth } from "~/server/lib/auth"; export const authClient = createAuthClient({ @@ -17,5 +18,6 @@ export const authClient = createAuthClient({ organizationClient(), ssoClient(), twoFactorClient(), + passkeyClient(), ], }); diff --git a/app/client/modules/auth/components/alternative-sign-in-section.tsx b/app/client/modules/auth/components/alternative-sign-in-section.tsx new file mode 100644 index 00000000..ca4f97bc --- /dev/null +++ b/app/client/modules/auth/components/alternative-sign-in-section.tsx @@ -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; +}; + +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 ( +
+

Alternative Sign-in

+
+ {loginOptions.hasPasskeySignIn && } + +
+
+ ); +} diff --git a/app/client/modules/auth/components/passkey-sign-in-button.tsx b/app/client/modules/auth/components/passkey-sign-in-button.tsx new file mode 100644 index 00000000..b5ce7117 --- /dev/null +++ b/app/client/modules/auth/components/passkey-sign-in-button.tsx @@ -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; +}; + +type PasskeySignInError = { + code?: string; + message?: string; + status?: number; + statusText?: string; +}; + +const LOGIN_ERROR_CODE_SET = new Set(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({ + 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 ( + + ); +} diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index 13c18da4..750ddde4 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -1,16 +1,49 @@ import { afterEach, describe, expect, test, vi } from "vitest"; 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) => { const actual = await importOriginal(); return { ...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(); + + 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"; const inviteOnlyMessage = "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(() => { + mockGetLoginOptions.mockClear(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false }); + mockNavigate.mockClear(); + mockPasskeySignIn.mockClear(); + mockPasskeySignIn.mockResolvedValue({ data: null, error: null }); + vi.unstubAllGlobals(); cleanup(); }); @@ -81,6 +120,14 @@ describe("LoginPage", () => { 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(, { withSuspense: true }); + + expect(await screen.findByText(getLoginErrorDescription(PASSKEY_LOGIN_FAILED_ERROR))).toBeTruthy(); + }); + test("does not show error message for invalid error codes", async () => { mockSsoProvidersRequest(); @@ -90,11 +137,141 @@ describe("LoginPage", () => { 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" }]); render(, { withSuspense: true }); 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(); + }); }); diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 612eaae1..842f3307 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -8,11 +8,25 @@ import { Button } from "~/client/components/ui/button"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; 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"; -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 [password, setPassword] = useState(""); + const [blockedMessage, setBlockedMessage] = useState(null); const downloadResticPassword = useMutation({ ...downloadResticPasswordMutation(), @@ -25,13 +39,16 @@ export function DownloadRecoveryKeyPage() { document.body.appendChild(a); a.click(); document.body.removeChild(a); - window.URL.revokeObjectURL(url); + window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); toast.success("Recovery key downloaded successfully!"); + setBlockedMessage(null); void navigate({ to: "/volumes", replace: true }); }, 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; } - downloadResticPassword.mutate({ - body: { - password, - }, - }); + setBlockedMessage(null); + downloadResticPassword.mutate({ body: { 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 ( @@ -59,30 +80,56 @@ export function DownloadRecoveryKeyPage() { Important: Save This File Securely - Your Restic password is essential for recovering your backup data. If you lose access to this server without - this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage. + Your Restic password is essential for recovering your backup data. If you previously downloaded this + 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.
-
- - setPassword(e.target.value)} - placeholder="Enter your password" - required - disabled={downloadResticPassword.isPending} - /> -

Enter your account password to download the recovery key

-
+ {(!hasCredentialPassword || blockedMessage) && ( + + + Local password required + + {blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE} + + + )} + + {hasCredentialPassword && ( +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + disabled={downloadResticPassword.isPending} + /> +

+ Enter your account password to download the recovery key +

+
+ )}
- + )} +
diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 9e9aa9f3..a9081fe7 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; 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 { authClient } from "~/client/lib/auth-client"; 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 { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { useNavigate } from "@tanstack/react-router"; import { normalizeUsername } from "~/lib/username"; 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"; const loginSchema = z.object({ @@ -32,6 +34,23 @@ type LoginPageProps = { 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 = {}) { const navigate = useNavigate(); const [showResetDialog, setShowResetDialog] = useState(false); @@ -43,6 +62,52 @@ export function LoginPage({ error }: LoginPageProps = {}) { const errorCode = decodeLoginError(error); 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({ resolver: zodResolver(loginSchema), defaultValues: { @@ -76,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { return; } - const d = await authClient.getSession(); - if (data.user && !d.data?.user.hasDownloadedResticPassword) { - void navigate({ to: "/download-recovery-key" }); - } else { - void navigate({ to: "/volumes" }); - } + await navigateAfterLogin(); }; const handleVerify2FA = async () => { @@ -113,7 +173,11 @@ export function LoginPage({ error }: LoginPageProps = {}) { if (data) { toast.success("Login successful"); 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" }); } else { void navigate({ to: "/volumes" }); @@ -130,7 +194,10 @@ export function LoginPage({ error }: LoginPageProps = {}) { if (requires2FA) { return ( - +
@@ -161,6 +228,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { setTrustDevice(e.target.checked)} className="h-4 w-4" @@ -199,7 +267,11 @@ export function LoginPage({ error }: LoginPageProps = {}) {
-
+
{errorDescription}
Username - + @@ -231,7 +309,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
- + @@ -243,7 +326,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { - +
diff --git a/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx b/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx index a6d865a3..b8528f73 100644 --- a/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx @@ -1,6 +1,6 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; 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 { Input } from "~/client/components/ui/input"; @@ -8,6 +8,74 @@ type AdvancedSectionProps = { form: UseFormReturn; }; +type WebhookFieldsProps = { + form: UseFormReturn; + 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 ( + <> + ( + + {labelPrefix} webhook + + + + + {description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed. + + + + )} + /> + ( + + {labelPrefix} webhook headers + +