Merge branch 'main' into main
This commit is contained in:
commit
739d828e22
336 changed files with 40503 additions and 5976 deletions
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
|
|||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||
APP_SECRET=<openssl rand -hex 32>
|
||||
BASE_URL=http://localhost:300
|
||||
BASE_URL=http://*.localhost
|
||||
TRUST_PROXY=false
|
||||
PORT=3000
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -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"],
|
||||
|
|
|
|||
3
.github/workflows/docs-deploy.yml
vendored
3
.github/workflows/docs-deploy.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
35
.github/workflows/integration.yml
vendored
Normal file
35
.github/workflows/integration.yml
vendored
Normal 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
|
||||
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
AGENTS.md
17
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
|
||||
|
|
|
|||
17
Dockerfile
17
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
|
||||
# ------------------------------
|
||||
|
|
|
|||
126
README.md
126
README.md
|
|
@ -20,13 +20,13 @@
|
|||
[](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.
|
||||
|
||||
<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>
|
||||
</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.
|
||||
|
||||
|
|
@ -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://<your
|
|||
|
||||
Zerobyte can be customized using environment variables. Below are the available options:
|
||||
|
||||
### Environment Variables
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||
| `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_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
|
||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
|
||||
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (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) |
|
||||
| Variable | Description | Default |
|
||||
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||
| `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_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
|
||||
| `PORT` | The port the web interface and API will listen on. | `4096` |
|
||||
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
|
||||
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
|
||||
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
|
||||
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
|
||||
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
|
||||
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
|
||||
| `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.
|
||||
|
||||
|
|
@ -127,14 +149,14 @@ The complete provisioning documentation is available at [zerobyte.app/docs/guide
|
|||
|
||||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.35
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.37
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -152,8 +174,8 @@ services:
|
|||
**Trade-offs:**
|
||||
|
||||
- ✅ Improved security by reducing container capabilities
|
||||
- ✅ Support for local directories
|
||||
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
|
||||
- ✅ Support for local directories as backup sources
|
||||
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
|
||||
- ❌ 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`.
|
||||
|
|
@ -168,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.
|
||||
|
||||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.35
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.37
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -201,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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating a repository
|
||||
|
||||
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
|
||||
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||
- **Google Cloud Storage** - Google's cloud storage service
|
||||
- **Azure Blob Storage** - Microsoft Azure storage
|
||||
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
|
||||
- **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.
|
||||
- **Google Cloud Storage**: Google's cloud storage service
|
||||
- **Azure Blob Storage**: Microsoft Azure storage
|
||||
- **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.
|
||||
|
||||
|
|
@ -248,7 +270,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.35
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.37
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -302,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.
|
||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
### 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.
|
||||
- **Domain / HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
|
||||
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.
|
||||
|
||||
### 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.):
|
||||
|
||||
|
|
@ -327,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
|
||||
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
|
||||
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
|
||||
|
|
@ -338,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.
|
||||
|
||||
## Third-Party Software
|
||||
## Third-party software
|
||||
|
||||
This project includes the following third-party software components:
|
||||
|
||||
|
|
@ -376,10 +404,10 @@ RESTIC_PASS_FILE=./data/restic.pass
|
|||
RESTIC_CACHE_DIR=./data/restic/cache
|
||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||
BASE_URL=http://localhost:4096
|
||||
BASE_URL=https://*.localhost
|
||||
```
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -49,10 +49,7 @@ export const createClient = (config: Config = {}): Client => {
|
|||
};
|
||||
|
||||
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<HttpMethod>) => (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<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export interface ResolvedRequestOptions<
|
|||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
headers: Headers;
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
|
|
@ -127,8 +128,10 @@ export type RequestResult<
|
|||
error: TError extends Record<string, unknown> ? 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;
|
||||
}
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -119,14 +119,12 @@ const checkForExistence = (
|
|||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
export async function setAuthParams(
|
||||
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
},
|
||||
): Promise<void> {
|
||||
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<Err, Res, Req, Options> = (
|
||||
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<Err>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
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;
|
||||
|
|
@ -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<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;
|
||||
};
|
||||
|
|
@ -2747,6 +2792,18 @@ export type CreateBackupScheduleResponses = {
|
|||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
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;
|
||||
|
|
@ -2816,6 +2873,18 @@ export type GetBackupScheduleResponses = {
|
|||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
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;
|
||||
|
|
@ -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<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;
|
||||
};
|
||||
|
|
@ -3130,6 +3214,18 @@ export type UpdateBackupScheduleResponses = {
|
|||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
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;
|
||||
|
|
@ -3179,6 +3275,18 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
includePatterns: Array<string> | null;
|
||||
oneFileSystem: boolean;
|
||||
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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
54
app/client/components/data-table-sort-header.tsx
Normal file
54
app/client/components/data-table-sort-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function Layout({ loaderData }: Props) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={buttonVariants({
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{label}</p>
|
||||
<p className="capitalize">{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
|
||||
return <hr data-slot="input-otp-separator" {...props} />;
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Sonner
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Cloud, Folder, Server } from "lucide-react";
|
||||
import type { BackendType } from "~/schemas/volumes";
|
||||
import type { BackendType } from "@zerobyte/contracts/volumes";
|
||||
|
||||
type VolumeIconProps = {
|
||||
backend: BackendType;
|
||||
|
|
|
|||
25
app/client/components/webhook-request-preview.tsx
Normal file
25
app/client/components/webhook-request-preview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -28,6 +28,7 @@ const invalidatingEvents = new Set<ServerEventType>([
|
|||
"backup:completed",
|
||||
"volume:updated",
|
||||
"volume:status_changed",
|
||||
"notification:updated",
|
||||
"mirror:completed",
|
||||
"doctor:started",
|
||||
"doctor:completed",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof import("@tanstack/react-router")>();
|
||||
|
||||
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<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";
|
||||
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(<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 () => {
|
||||
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(<LoginPage />, { 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(<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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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() {
|
|||
<AlertTriangle className="size-5" />
|
||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||
<AlertDescription>
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
{(!hasCredentialPassword || blockedMessage) && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="size-5" />
|
||||
<AlertTitle>Local password required</AlertTitle>
|
||||
<AlertDescription>
|
||||
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasCredentialPassword && (
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Recovery Key
|
||||
{hasCredentialPassword && (
|
||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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<LoginFormValues>({
|
||||
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 (
|
||||
<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-4 flex flex-col items-center">
|
||||
<Label htmlFor="totp-code">Authentication code</Label>
|
||||
|
|
@ -161,6 +228,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
<input
|
||||
type="checkbox"
|
||||
id="trust-device"
|
||||
aria-label="Trust this device for 30 days"
|
||||
checked={trustDevice}
|
||||
onChange={(e) => setTrustDevice(e.target.checked)}
|
||||
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">
|
||||
<Form {...form}>
|
||||
<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}
|
||||
</div>
|
||||
<FormField
|
||||
|
|
@ -209,7 +281,13 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -231,7 +309,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" disabled={isLoggingIn} />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="current-password webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -243,7 +326,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
|||
</form>
|
||||
</Form>
|
||||
|
||||
<SsoLoginSection />
|
||||
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
|
||||
|
||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||
</AuthLayout>
|
||||
|
|
|
|||
|
|
@ -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<InternalFormValues>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldPrefix}Url`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{labelPrefix} webhook</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="url" placeholder={urlPlaceholder} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldPrefix}HeadersText`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{labelPrefix} webhook headers</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Authorization: Bearer token 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 (
|
||||
<>
|
||||
|
|
@ -56,6 +124,20 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
|||
</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"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { PathsSection } from "./paths-section";
|
|||
import { RetentionSection } from "./retention-section";
|
||||
import { SummarySection } from "./summary-section";
|
||||
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
|
||||
import { backupScheduleToFormValues, parseMultilineEntries } from "./utils";
|
||||
import { backupScheduleToFormValues, parseMultilineEntries, toWebhookConfig } from "./utils";
|
||||
|
||||
export type { BackupScheduleFormValues };
|
||||
|
||||
|
|
@ -46,12 +46,24 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
cronExpression,
|
||||
maxRetries,
|
||||
retryDelay,
|
||||
preBackupWebhookUrl,
|
||||
preBackupWebhookHeadersText,
|
||||
preBackupWebhookBody,
|
||||
postBackupWebhookUrl,
|
||||
postBackupWebhookHeadersText,
|
||||
postBackupWebhookBody,
|
||||
...rest
|
||||
} = data;
|
||||
const excludePatterns = parseMultilineEntries(excludePatternsText);
|
||||
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
|
||||
const parsedIncludePatterns = parseMultilineEntries(includePatterns);
|
||||
const customResticParams = parseMultilineEntries(customResticParamsText);
|
||||
const preBackupWebhook = toWebhookConfig(preBackupWebhookUrl, preBackupWebhookHeadersText, preBackupWebhookBody);
|
||||
const postBackupWebhook = toWebhookConfig(
|
||||
postBackupWebhookUrl,
|
||||
postBackupWebhookHeadersText,
|
||||
postBackupWebhookBody,
|
||||
);
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
|
|
@ -61,6 +73,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
customResticParams,
|
||||
backupWebhooks:
|
||||
preBackupWebhook || postBackupWebhook ? { pre: preBackupWebhook, post: postBackupWebhook } : null,
|
||||
maxRetries,
|
||||
retryDelay,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
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({
|
||||
name: z.string().min(1).max(128),
|
||||
|
|
@ -20,6 +35,12 @@ export const internalFormSchema = z.object({
|
|||
keepYearly: z.number().optional(),
|
||||
oneFileSystem: z.boolean().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(),
|
||||
});
|
||||
|
|
@ -38,10 +59,20 @@ export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
|||
|
||||
export type BackupScheduleFormValues = Omit<
|
||||
InternalFormValues,
|
||||
"excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText"
|
||||
| "excludePatternsText"
|
||||
| "excludeIfPresentText"
|
||||
| "includePatterns"
|
||||
| "customResticParamsText"
|
||||
| "preBackupWebhookUrl"
|
||||
| "preBackupWebhookHeadersText"
|
||||
| "preBackupWebhookBody"
|
||||
| "postBackupWebhookUrl"
|
||||
| "postBackupWebhookHeadersText"
|
||||
| "postBackupWebhookBody"
|
||||
> & {
|
||||
excludePatterns?: string[];
|
||||
excludeIfPresent?: string[];
|
||||
includePatterns?: string[];
|
||||
customResticParams?: string[];
|
||||
backupWebhooks?: BackupWebhooks | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,13 +2,29 @@ import type { BackupSchedule } from "~/client/lib/types";
|
|||
import { cronToFormValues } from "../../lib/cron-utils";
|
||||
import type { InternalFormValues } from "./types";
|
||||
|
||||
export const parseMultilineEntries = (value?: string) =>
|
||||
value
|
||||
? value
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
export const parseMultilineEntries = (value?: string) => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (!schedule) {
|
||||
|
|
@ -26,6 +42,12 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
|||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const schedule = {
|
|||
excludeIfPresent: [],
|
||||
oneFileSystem: false,
|
||||
customResticParams: [],
|
||||
backupWebhooks: null,
|
||||
};
|
||||
|
||||
const snapshot = {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const renderEditBackupPage = ({ enabled, cronExpression }: { enabled: boolean; c
|
|||
excludeIfPresent: [],
|
||||
oneFileSystem: false,
|
||||
customResticParams: [],
|
||||
backupWebhooks: null,
|
||||
});
|
||||
}),
|
||||
http.get("/api/v1/repositories", () => {
|
||||
|
|
@ -124,3 +125,28 @@ test("preserves a disabled schedule when saving a non-manual frequency", async (
|
|||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }),
|
||||
...listSnapshotsOptions({
|
||||
path: { shortId: schedule.repository.shortId },
|
||||
query: { backupId: schedule.shortId },
|
||||
}),
|
||||
initialData: loaderData.snapshots,
|
||||
});
|
||||
|
||||
|
|
@ -167,6 +170,9 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
excludeIfPresent: schedule.excludeIfPresent || [],
|
||||
oneFileSystem: schedule.oneFileSystem,
|
||||
customResticParams: schedule.customResticParams || [],
|
||||
backupWebhooks: schedule.backupWebhooks,
|
||||
maxRetries: schedule.maxRetries,
|
||||
retryDelay: schedule.retryDelay,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -252,8 +258,8 @@ export function ScheduleDetailsPage(props: Props) {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||
repository.
|
||||
This action cannot be undone. This will permanently delete the snapshot and all its data
|
||||
from the repository.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export function CreateBackupPage() {
|
|||
excludeIfPresent: formValues.excludeIfPresent,
|
||||
oneFileSystem: formValues.oneFileSystem,
|
||||
customResticParams: formValues.customResticParams,
|
||||
backupWebhooks: formValues.backupWebhooks,
|
||||
maxRetries: formValues.maxRetries,
|
||||
retryDelay: formValues.retryDelay,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => {
|
|||
>
|
||||
Shoutrrr documentation
|
||||
</a>
|
||||
for supported services and URL formats.
|
||||
for supported services and URL formats. Custom HTTP, generic, and SMTP network targets must be listed
|
||||
in WEBHOOK_ALLOWED_ORIGINS.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
|
||||
import type { NotificationFormValues } from "../create-notification-form";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -16,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
|
|||
if (values.type !== "generic") return null;
|
||||
|
||||
const contentType = values.contentType || "application/json";
|
||||
const headers = values.headers || [];
|
||||
const headers = values.headers?.filter(Boolean) || [];
|
||||
const useJson = values.useJson;
|
||||
const titleKey = values.titleKey || "title";
|
||||
const messageKey = values.messageKey || "message";
|
||||
|
|
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
|
|||
body = "Notification message";
|
||||
}
|
||||
|
||||
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.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>
|
||||
<WebhookRequestPreview
|
||||
method={values.method || "POST"}
|
||||
url={values.url}
|
||||
contentType={contentType}
|
||||
headers={headers}
|
||||
body={body}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -63,7 +60,7 @@ export const GenericForm = ({ form }: Props) => {
|
|||
<FormControl>
|
||||
<Input {...field} placeholder="https://api.example.com/webhook" />
|
||||
</FormControl>
|
||||
<FormDescription>The target URL for the webhook.</FormDescription>
|
||||
<FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -113,10 +110,19 @@ export const GenericForm = ({ form }: Props) => {
|
|||
{...field}
|
||||
placeholder="Authorization: Bearer token X-Custom-Header: value"
|
||||
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>
|
||||
<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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => {
|
|||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => {
|
|||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ import {
|
|||
Lock,
|
||||
Mail,
|
||||
AtSign,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Server,
|
||||
|
|
@ -55,6 +57,7 @@ import type { GetNotificationDestinationResponse } from "~/client/api-client/typ
|
|||
import { useTimeFormat } from "~/client/lib/datetime";
|
||||
|
||||
type NotificationConfig = GetNotificationDestinationResponse["config"];
|
||||
type NotificationStatus = GetNotificationDestinationResponse["status"];
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
|
|
@ -72,6 +75,17 @@ 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 }) {
|
||||
switch (config.type) {
|
||||
case "slack":
|
||||
|
|
@ -228,13 +242,8 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
|||
<h2 className="text-lg font-semibold tracking-tight">{data.name}</h2>
|
||||
<Separator orientation="vertical" className="h-4 mx-1" />
|
||||
<Badge variant="outline" className="capitalize gap-1.5">
|
||||
<span
|
||||
className={cn("w-2 h-2 rounded-full shrink-0", {
|
||||
"bg-success": data.enabled,
|
||||
"bg-red-500": !data.enabled,
|
||||
})}
|
||||
/>
|
||||
{data.enabled ? "Enabled" : "Disabled"}
|
||||
<span className={cn("w-2 h-2 rounded-full shrink-0", getStatusClass(data.enabled, data.status))} />
|
||||
{getStatusLabel(data.enabled, data.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{data.type}
|
||||
|
|
@ -289,6 +298,34 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
|||
<NotificationConfigRows config={data.config} />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
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 { 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 { StatusDot } from "~/client/components/status-dot";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
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 { cn } from "~/client/lib/utils";
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
type NotificationRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "email" | "slack" | "discord" | "gotify" | "ntfy" | "pushover" | "telegram" | "custom" | "generic";
|
||||
enabled: boolean;
|
||||
status: "healthy" | "error" | "unknown";
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setTypeFilter("");
|
||||
setStatusFilter("");
|
||||
};
|
||||
const getNotificationStatus = (row: NotificationRow) => (row.enabled ? row.status : "disabled");
|
||||
const getNotificationStatusVariant = (row: NotificationRow) => {
|
||||
if (!row.enabled) return "neutral";
|
||||
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();
|
||||
|
||||
|
|
@ -29,15 +74,24 @@ export function NotificationsPage() {
|
|||
...listNotificationDestinationsOptions(),
|
||||
});
|
||||
|
||||
const filteredNotifications = data.filter((notification) => {
|
||||
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = !typeFilter || notification.type === typeFilter;
|
||||
const matchesStatus = !statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: notificationColumns,
|
||||
state: { columnFilters, sorting },
|
||||
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 hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
|
||||
const hasNoFilteredNotifications = rows.length === 0 && !hasNoNotifications;
|
||||
|
||||
if (hasNoNotifications) {
|
||||
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 (
|
||||
<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">
|
||||
|
|
@ -62,10 +120,10 @@ export function NotificationsPage() {
|
|||
<Input
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={search}
|
||||
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">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -77,19 +135,22 @@ export function NotificationsPage() {
|
|||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||
<SelectItem value="pushover">Pushover</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="generic">Generic</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</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">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || typeFilter || statusFilter) && (
|
||||
{hasFilters && (
|
||||
<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" />
|
||||
Clear filters
|
||||
|
|
@ -104,11 +165,22 @@ export function NotificationsPage() {
|
|||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-25 uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Type</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
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>
|
||||
<TableBody>
|
||||
<TableRow className={cn({ hidden: !hasNoFilteredNotifications })}>
|
||||
|
|
@ -122,30 +194,38 @@ export function NotificationsPage() {
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{filteredNotifications.map((notification) => (
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={notification.id}
|
||||
key={row.original.id}
|
||||
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>
|
||||
<TableCell className="capitalize text-muted-foreground">{notification.type}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusDot
|
||||
variant={notification.enabled ? "success" : "neutral"}
|
||||
label={notification.enabled ? "Enabled" : "Disabled"}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn({
|
||||
"font-medium text-strong-accent": cell.column.id === "name",
|
||||
"capitalize text-muted-foreground": cell.column.id === "type",
|
||||
"text-center": cell.column.id === "status",
|
||||
})}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
|
||||
{filteredNotifications.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{hasNoFilteredNotifications ? (
|
||||
"No destinations match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{rows.length}</span> destination
|
||||
{rows.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
|
|||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" 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 = ({
|
||||
|
|
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
|
||||
a custom password for the existing repository.
|
||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
|
||||
account) or enter a custom password for the existing repository.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
const cacert = useWatch({ control: form.control, name: "cacert" });
|
||||
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
|
||||
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
|
||||
const backend = useWatch({ control: form.control, name: "backend" });
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
|
|
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
placeholder="10"
|
||||
className="pr-12"
|
||||
{...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="h-4 w-px bg-border" />
|
||||
|
|
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
disabled={!downloadLimitEnabled}
|
||||
className="pr-12"
|
||||
{...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="h-4 w-px bg-border" />
|
||||
|
|
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !cacert })}>
|
||||
<p className="max-w-xs">
|
||||
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
|
||||
TLS validation instead.
|
||||
This option is disabled because a CA certificate is provided. Remove the CA
|
||||
certificate to skip TLS validation instead.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Skip TLS certificate verification</FormLabel>
|
||||
<FormDescription>
|
||||
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
|
||||
insecure and should only be used for testing.
|
||||
Disable TLS certificate verification for HTTPS connections with self-signed
|
||||
certificates. This is insecure and should only be used for testing.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
|
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={6}
|
||||
disabled={insecureTls}
|
||||
{...field}
|
||||
|
|
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !insecureTls })}>
|
||||
<p className="max-w-xs">
|
||||
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
|
||||
Verification" to provide a custom CA certificate.
|
||||
CA certificate is disabled because TLS validation is being skipped. Uncheck
|
||||
"Skip TLS Certificate Verification" to provide a custom CA certificate.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</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>
|
||||
</Collapsible>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
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 { useState } from "react";
|
||||
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 { Button } from "~/client/components/ui/button";
|
||||
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 { EmptyState } from "~/client/components/empty-state";
|
||||
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() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
setBackendFilter("");
|
||||
};
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -30,15 +97,23 @@ export function RepositoriesPage() {
|
|||
...listRepositoriesOptions(),
|
||||
});
|
||||
|
||||
const filteredRepositories = data.filter((repository) => {
|
||||
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = !statusFilter || repository.status === statusFilter;
|
||||
const matchesBackend = !backendFilter || repository.type === backendFilter;
|
||||
return matchesSearch && matchesStatus && matchesBackend;
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: repositoryColumns,
|
||||
state: { columnFilters, sorting },
|
||||
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 hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
|
||||
const hasNoFilteredRepositories = rows.length === 0 && !hasNoRepositories;
|
||||
|
||||
if (hasNoRepositories) {
|
||||
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 (
|
||||
<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">
|
||||
|
|
@ -63,10 +142,10 @@ export function RepositoriesPage() {
|
|||
<Input
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={search}
|
||||
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">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -76,7 +155,7 @@ export function RepositoriesPage() {
|
|||
<SelectItem value="unknown">Unknown</SelectItem>
|
||||
</SelectContent>
|
||||
</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">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -87,7 +166,7 @@ export function RepositoriesPage() {
|
|||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
{hasFilters && (
|
||||
<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" />
|
||||
Clear filters
|
||||
|
|
@ -102,12 +181,23 @@ export function RepositoriesPage() {
|
|||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-25 uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
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>
|
||||
<TableBody>
|
||||
<TableRow className={cn({ hidden: !hasNoFilteredRepositories })}>
|
||||
|
|
@ -121,38 +211,24 @@ export function RepositoriesPage() {
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{filteredRepositories.map((repository) => (
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={repository.id}
|
||||
key={row.original.id}
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{repository.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<RepositoryIcon backend={repository.type} />
|
||||
{repository.type}
|
||||
</span>
|
||||
</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>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn({
|
||||
"font-medium text-strong-accent": cell.column.id === "name",
|
||||
"hidden sm:table-cell": cell.column.id === "compressionMode",
|
||||
"text-center": cell.column.id === "status",
|
||||
})}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
@ -163,8 +239,8 @@ export function RepositoriesPage() {
|
|||
"No repositories match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor
|
||||
{filteredRepositories.length === 1 ? "y" : "ies"}
|
||||
<span className="text-strong-accent">{rows.length}</span> repositor
|
||||
{rows.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
308
app/client/modules/settings/components/passkeys-section.tsx
Normal file
308
app/client/modules/settings/components/passkeys-section.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
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 { 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 { 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();
|
||||
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>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add passkey
|
||||
</Button>
|
||||
</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}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,21 @@
|
|||
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 { toast } from "sonner";
|
||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -29,12 +39,18 @@ import {
|
|||
useTimeFormat,
|
||||
} from "~/client/lib/datetime";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { type AppContext } from "~/context";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
import { PasskeysSection } from "../components/passkeys-section";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||
import { OrgMembersSection } from "../components/org-members-section";
|
||||
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 = {
|
||||
appContext: AppContext;
|
||||
|
|
@ -49,6 +65,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const { dateFormat, timeFormat } = useRootLoaderData();
|
||||
|
||||
|
|
@ -59,6 +76,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
const { activeMember, activeOrganization } = useOrganizationContext();
|
||||
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut({
|
||||
|
|
@ -85,15 +103,18 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
||||
|
||||
toast.success("Restic password file downloaded successfully");
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
setDownloadBlockedMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = parseError(error)?.message;
|
||||
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
|
||||
toast.error("Failed to download Restic password", {
|
||||
description: error.message,
|
||||
description: message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -145,6 +166,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
return;
|
||||
}
|
||||
|
||||
setDownloadBlockedMessage(null);
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password: downloadPassword,
|
||||
|
|
@ -213,11 +235,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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>
|
||||
</CardContent>
|
||||
|
||||
|
|
@ -237,7 +270,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<Label htmlFor="date-format">Date format</Label>
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
|
||||
onValueChange={(value) =>
|
||||
void handleDateFormatChange(value as DateFormatPreference)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="date-format">
|
||||
<SelectValue />
|
||||
|
|
@ -255,7 +290,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<Label htmlFor="time-format">Time format</Label>
|
||||
<Select
|
||||
value={timeFormat}
|
||||
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
|
||||
onValueChange={(value) =>
|
||||
void handleTimeFormatChange(value as TimeFormatPreference)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="time-format">
|
||||
<SelectValue />
|
||||
|
|
@ -270,18 +307,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
</Select>
|
||||
</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>
|
||||
</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">
|
||||
<KeyRound className="size-5" />
|
||||
Change Password
|
||||
</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>
|
||||
<CardContent className="p-6">
|
||||
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
|
|
@ -305,7 +350,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
required
|
||||
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 className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
|
|
@ -331,13 +378,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<Download className="size-5" />
|
||||
Backup Recovery Key
|
||||
</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>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<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
|
||||
place (like a password manager or encrypted storage). If you lose access to this server, you'll need
|
||||
this file to recover your backup data.
|
||||
This file contains the encryption password used by Restic to secure your backups.
|
||||
Store it in a safe place (like a password manager or encrypted storage). If you lose
|
||||
access to this server, you'll need this file to recover your backup data.
|
||||
</p>
|
||||
|
||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||
|
|
@ -352,11 +401,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<DialogHeader>
|
||||
<DialogTitle>Download Recovery Key</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<Input
|
||||
id="download-password"
|
||||
|
|
@ -380,7 +444,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</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
|
||||
</Button>
|
||||
|
|
@ -391,6 +459,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
</CardContent>
|
||||
|
||||
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
|
||||
|
||||
<PasskeysSection />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -402,7 +472,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<Fingerprint className="size-5" />
|
||||
Organization Details
|
||||
</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>
|
||||
<CardContent className="p-6 space-y-2">
|
||||
<Label htmlFor="organization-id">Organization ID</Label>
|
||||
|
|
@ -421,7 +493,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<Building2 className="size-5" />
|
||||
Members
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
|
||||
<CardDescription className="mt-1.5">
|
||||
Manage organization members and roles
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<OrgMembersSection initialMembers={initialMembers} />
|
||||
|
|
@ -434,10 +508,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
<SettingsIcon className="size-5" />
|
||||
Single Sign-On
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
||||
<CardDescription className="mt-1.5">
|
||||
Configure OIDC provider settings
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
|
||||
<SsoSettingsSection
|
||||
initialSettings={initialSsoSettings}
|
||||
initialOrigin={initialOrigin}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
|
|||
54
app/client/modules/sso/components/sso-login-buttons.tsx
Normal file
54
app/client/modules/sso/components/sso-login-buttons.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ import {
|
|||
smbConfigSchema,
|
||||
volumeConfigSchema,
|
||||
webdavConfigSchema,
|
||||
} from "~/schemas/volumes";
|
||||
} from "@zerobyte/contracts/volumes";
|
||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
|
|
@ -66,10 +66,10 @@ type Props = {
|
|||
const defaultValuesForType = {
|
||||
directory: { backend: "directory" as const, path: "/" },
|
||||
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" },
|
||||
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) => {
|
||||
|
|
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
|
||||
<SelectItem
|
||||
disabled={!capabilities.rclone || !capabilities.sysAdmin}
|
||||
value="rclone"
|
||||
>
|
||||
rclone
|
||||
</SelectItem>
|
||||
</div>
|
||||
|
|
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
|
||||
<p>Remote mounts require SYS_ADMIN capability</p>
|
||||
</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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const NFSForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Mount volume as read-only"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export const RcloneForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Mount volume as read-only"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
|
|||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import { Textarea } from "../../../../components/ui/textarea";
|
||||
import { Switch } from "../../../../components/ui/switch";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<FormValues>;
|
||||
|
|
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
<FormControl>
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
|
||||
<FormDescription>
|
||||
Password for SFTP authentication (optional if using private key).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
|
||||
<FormDescription>
|
||||
SSH private key for authentication (optional if using password).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pb-4 pt-4">
|
||||
<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>
|
||||
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export const SMBForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Connect as guest"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.checked);
|
||||
|
|
@ -168,6 +169,33 @@ export const SMBForm = ({ form }: Props) => {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mapToContainerUidGid"
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ownership Mapping</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Map all files to container user/group"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Map all files to container user/group</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Keep the old behavior by forcing the SMB mount to present every file and directory as owned
|
||||
by the container user and group instead of using server reported ownership.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="readOnly"
|
||||
|
|
@ -179,6 +207,7 @@ export const SMBForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Mount volume as read-only"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export const WebDAVForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Enable HTTPS for secure connections"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
|
|
@ -128,6 +129,7 @@ export const WebDAVForm = ({ form }: Props) => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Mount volume as read-only"
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { listVolumesOptions } 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 { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -9,7 +21,6 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
|
@ -24,33 +35,66 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "
|
|||
return statusMap[status];
|
||||
};
|
||||
|
||||
export function VolumesPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
type VolumeRow = {
|
||||
shortId: string;
|
||||
name: string;
|
||||
type: "directory" | "nfs" | "smb" | "webdav" | "sftp" | "rclone";
|
||||
status: VolumeStatus;
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
setBackendFilter("");
|
||||
};
|
||||
const volumeColumns: ColumnDef<VolumeRow>[] = [
|
||||
{
|
||||
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 }) => <VolumeIcon backend={row.original.type} />,
|
||||
filterFn: (row, id, value) => row.getValue(id) === value,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
|
||||
),
|
||||
cell: ({ row }) => <StatusDot variant={getVolumeStatusVariant(row.original.status)} label={row.original.status} />,
|
||||
filterFn: (row, id, value) => row.getValue(id) === value,
|
||||
},
|
||||
];
|
||||
|
||||
export function VolumesPage() {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data } = useSuspenseQuery({ ...listVolumesOptions() });
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
...listVolumesOptions(),
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: volumeColumns,
|
||||
state: { columnFilters, sorting },
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const filteredVolumes =
|
||||
data.filter((volume) => {
|
||||
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = !statusFilter || volume.status === statusFilter;
|
||||
const matchesBackend = !backendFilter || volume.type === backendFilter;
|
||||
return matchesSearch && matchesStatus && matchesBackend;
|
||||
}) || [];
|
||||
const rows = table.getRowModel().rows;
|
||||
const hasFilters = columnFilters.length > 0;
|
||||
|
||||
const clearFilters = () => table.resetColumnFilters();
|
||||
|
||||
const hasNoVolumes = data.length === 0;
|
||||
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||
const hasNoFilteredVolumes = rows.length === 0 && !hasNoVolumes;
|
||||
|
||||
if (hasNoVolumes) {
|
||||
return (
|
||||
|
|
@ -68,6 +112,10 @@ export function VolumesPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const search = (table.getColumn("name")?.getFilterValue() as string) ?? "";
|
||||
const status = (table.getColumn("status")?.getFilterValue() as string) ?? "";
|
||||
const type = (table.getColumn("type")?.getFilterValue() as string) ?? "";
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -75,10 +123,10 @@ export function VolumesPage() {
|
|||
<Input
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={search}
|
||||
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">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -88,7 +136,7 @@ export function VolumesPage() {
|
|||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</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">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -101,7 +149,7 @@ export function VolumesPage() {
|
|||
<SelectItem value="rclone">rclone</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
{hasFilters && (
|
||||
<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" />
|
||||
Clear filters
|
||||
|
|
@ -116,15 +164,26 @@ export function VolumesPage() {
|
|||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-25 uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
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>
|
||||
<TableBody>
|
||||
<TableRow className={cn({ hidden: !hasNoFilteredVolumes })}>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No volumes match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
|
|
@ -134,26 +193,24 @@ export function VolumesPage() {
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{filteredVolumes.map((volume) => (
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={volume.shortId}
|
||||
key={row.original.shortId}
|
||||
className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
|
||||
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
|
||||
onClick={() => navigate({ to: `/volumes/${row.original.shortId}` })}
|
||||
>
|
||||
<TableCell className="font-medium font-mono text-strong-accent">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{volume.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
<VolumeIcon backend={volume.type} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono">
|
||||
<StatusDot
|
||||
variant={getVolumeStatusVariant(volume.status)}
|
||||
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn("font-mono", {
|
||||
"font-medium text-strong-accent": cell.column.id === "name",
|
||||
"text-muted-foreground": cell.column.id === "type",
|
||||
"text-center": cell.column.id === "status",
|
||||
})}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
@ -164,8 +221,8 @@ export function VolumesPage() {
|
|||
"No volumes match filters."
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
<span className="text-strong-accent font-bold">{filteredVolumes.length}</span> volume
|
||||
{filteredVolumes.length > 1 ? "s" : ""}
|
||||
<span className="text-strong-accent font-bold">{rows.length}</span> volume
|
||||
{rows.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
|
|||
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
|
||||
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
|
||||
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,12 +45,19 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
|||
|
||||
switch (config.backend) {
|
||||
case "directory":
|
||||
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
|
||||
return (
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
|
||||
);
|
||||
case "nfs":
|
||||
return (
|
||||
<>
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
|
||||
<ConfigRow
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label="Export Path"
|
||||
value={config.exportPath}
|
||||
mono
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "smb":
|
||||
|
|
@ -84,6 +93,8 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
|||
}
|
||||
|
||||
function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||
const { used = 0, total = 0 } = statfs;
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
|
||||
|
|
@ -93,8 +104,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
);
|
||||
|
||||
const usagePercentage = useMemo(() => {
|
||||
return Math.round((statfs.used / statfs.total) * 100);
|
||||
}, [statfs]);
|
||||
return Math.round((used / total) * 100);
|
||||
}, [used, total]);
|
||||
|
||||
return (
|
||||
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
|
||||
|
|
@ -114,10 +125,18 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-2xl font-bold"
|
||||
>
|
||||
{usagePercentage}%
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
className="fill-muted-foreground text-xs"
|
||||
>
|
||||
Used
|
||||
</tspan>
|
||||
</text>
|
||||
|
|
@ -132,7 +151,9 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
}
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
const hasStorage = statfs.total > 0;
|
||||
const { total = 0, used = 0, free = 0 } = statfs;
|
||||
|
||||
const hasStorage = total > 0;
|
||||
|
||||
return (
|
||||
<Card className="px-6 py-6 @container/inner">
|
||||
|
|
@ -144,7 +165,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
</CardTitle>
|
||||
<div className="space-y-0 divide-y divide-border/50">
|
||||
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
|
||||
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
|
||||
<ConfigRow
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label="Backend"
|
||||
value={backendLabels[volume.type]}
|
||||
/>
|
||||
<BackendConfigRows volume={volume} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -162,21 +187,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Total</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||
<ByteSize bytes={total} className="font-mono text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-strong-accent" />
|
||||
<span className="text-sm font-medium">Used</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
|
||||
<ByteSize bytes={used} className="font-mono text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-primary" />
|
||||
<span className="text-sm font-medium">Free</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
|
||||
<ByteSize bytes={free} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type User = {
|
|||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
hasCredentialPassword?: boolean;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
|
||||
|
|
@ -14,4 +15,5 @@ export type AppContext = {
|
|||
user: User | null;
|
||||
hasUsers: boolean;
|
||||
sidebarOpen: boolean;
|
||||
hasSkippedRecoveryKeyDownload: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `volumes_table` ADD `agent_id` text DEFAULT 'local' NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX `volumes_table_agent_id_idx` ON `volumes_table` (`agent_id`);
|
||||
2505
app/drizzle/20260416123510_supreme_stone_men/snapshot.json
Normal file
2505
app/drizzle/20260416123510_supreme_stone_men/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
17
app/drizzle/20260428161759_productive_namor/migration.sql
Normal file
17
app/drizzle/20260428161759_productive_namor/migration.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE `passkey` (
|
||||
`id` text PRIMARY KEY,
|
||||
`name` text,
|
||||
`public_key` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`credential_id` text NOT NULL,
|
||||
`counter` integer NOT NULL,
|
||||
`device_type` text NOT NULL,
|
||||
`backed_up` integer NOT NULL,
|
||||
`transports` text,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`aaguid` text,
|
||||
CONSTRAINT `fk_passkey_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);
|
||||
2491
app/drizzle/20260428161759_productive_namor/snapshot.json
Normal file
2491
app/drizzle/20260428161759_productive_namor/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1
app/drizzle/20260429214453_classy_namora/migration.sql
Normal file
1
app/drizzle/20260429214453_classy_namora/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `backup_schedules_table` ADD `backup_webhooks` text;
|
||||
2341
app/drizzle/20260429214453_classy_namora/snapshot.json
Normal file
2341
app/drizzle/20260429214453_classy_namora/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE `notification_destinations_table` ADD `status` text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `notification_destinations_table` ADD `last_checked` integer;--> statement-breakpoint
|
||||
ALTER TABLE `notification_destinations_table` ADD `last_error` text;
|
||||
2371
app/drizzle/20260502085340_sudden_maria_hill/snapshot.json
Normal file
2371
app/drizzle/20260502085340_sudden_maria_hill/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
16
app/drizzle/20260505165117_early_purple_man/migration.sql
Normal file
16
app/drizzle/20260505165117_early_purple_man/migration.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TABLE `agents_table` (
|
||||
`id` text PRIMARY KEY,
|
||||
`organization_id` text,
|
||||
`name` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`status` text DEFAULT 'offline' NOT NULL,
|
||||
`capabilities` text DEFAULT '{}' NOT NULL,
|
||||
`last_seen_at` integer,
|
||||
`last_ready_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
CONSTRAINT `fk_agents_table_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `agents_table_organization_id_idx` ON `agents_table` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `agents_table_status_idx` ON `agents_table` (`status`);
|
||||
2521
app/drizzle/20260505165117_early_purple_man/snapshot.json
Normal file
2521
app/drizzle/20260505165117_early_purple_man/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
28
app/drizzle/20260518202140_numerous_prodigy/migration.sql
Normal file
28
app/drizzle/20260518202140_numerous_prodigy/migration.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CREATE TABLE `repository_lock_waiters` (
|
||||
`id` text PRIMARY KEY,
|
||||
`repository_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`owner_id` text NOT NULL,
|
||||
`requested_at` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`heartbeat_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `repository_locks` (
|
||||
`id` text PRIMARY KEY,
|
||||
`repository_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`owner_id` text NOT NULL,
|
||||
`acquired_at` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`heartbeat_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `repository_lock_waiters_repository_id_idx` ON `repository_lock_waiters` (`repository_id`);--> statement-breakpoint
|
||||
CREATE INDEX `repository_lock_waiters_expires_at_idx` ON `repository_lock_waiters` (`expires_at`);--> statement-breakpoint
|
||||
CREATE INDEX `repository_lock_waiters_owner_id_idx` ON `repository_lock_waiters` (`owner_id`);--> statement-breakpoint
|
||||
CREATE INDEX `repository_locks_repository_id_idx` ON `repository_locks` (`repository_id`);--> statement-breakpoint
|
||||
CREATE INDEX `repository_locks_expires_at_idx` ON `repository_locks` (`expires_at`);--> statement-breakpoint
|
||||
CREATE INDEX `repository_locks_owner_id_idx` ON `repository_locks` (`owner_id`);
|
||||
2811
app/drizzle/20260518202140_numerous_prodigy/snapshot.json
Normal file
2811
app/drizzle/20260518202140_numerous_prodigy/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1
app/drizzle/20260522174225_organic_nehzno/migration.sql
Normal file
1
app/drizzle/20260522174225_organic_nehzno/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;
|
||||
2821
app/drizzle/20260522174225_organic_nehzno/snapshot.json
Normal file
2821
app/drizzle/20260522174225_organic_nehzno/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
22
app/drizzle/20260530131042_wakeful_santa_claus/migration.sql
Normal file
22
app/drizzle/20260530131042_wakeful_santa_claus/migration.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
CREATE TABLE `tasks` (
|
||||
`id` text PRIMARY KEY,
|
||||
`organization_id` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`resource_type` text NOT NULL,
|
||||
`resource_id` text NOT NULL,
|
||||
`target_agent_id` text,
|
||||
`input` text NOT NULL,
|
||||
`progress` text,
|
||||
`result` text,
|
||||
`error` text,
|
||||
`cancellation_requested` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`started_at` integer,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`finished_at` integer,
|
||||
CONSTRAINT `fk_tasks_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `tasks_org_kind_resource_status_idx` ON `tasks` (`organization_id`,`kind`,`resource_type`,`resource_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_org_status_updated_at_idx` ON `tasks` (`organization_id`,`status`,`updated_at`);
|
||||
3215
app/drizzle/20260530131042_wakeful_santa_claus/snapshot.json
Normal file
3215
app/drizzle/20260530131042_wakeful_santa_claus/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2
app/lib/recovery-key-skip.ts
Normal file
2
app/lib/recovery-key-skip.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME = "zerobyte_recovery_key_download_skipped";
|
||||
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE = 60 * 60;
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
|
||||
|
||||
export const LOGIN_ERROR_CODES = [
|
||||
"ACCOUNT_LINK_REQUIRED",
|
||||
"EMAIL_NOT_VERIFIED",
|
||||
"INVITE_REQUIRED",
|
||||
"BANNED_USER",
|
||||
"SSO_LOGIN_FAILED",
|
||||
PASSKEY_LOGIN_FAILED_ERROR,
|
||||
"ERROR_INVALID_RP_ID",
|
||||
] as const;
|
||||
|
||||
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
|
||||
|
|
@ -20,5 +24,9 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
|
|||
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||
case "SSO_LOGIN_FAILED":
|
||||
return "SSO authentication failed. Please try again.";
|
||||
case PASSKEY_LOGIN_FAILED_ERROR:
|
||||
return "Passkey sign-in failed. The passkey didn't verify your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.";
|
||||
case "ERROR_INVALID_RP_ID":
|
||||
return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
import { isAuthRoute } from "~/lib/auth-routes";
|
||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
||||
|
||||
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||
const headers = getRequestHeaders();
|
||||
|
|
@ -20,7 +21,13 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
|
|||
}
|
||||
|
||||
if (session?.user?.id) {
|
||||
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
|
||||
const hasSkippedRecoveryKeyDownload = getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
|
||||
|
||||
if (
|
||||
!session.user.hasDownloadedResticPassword &&
|
||||
!hasSkippedRecoveryKeyDownload &&
|
||||
pathname !== "/download-recovery-key"
|
||||
) {
|
||||
throw redirect({ to: "/download-recovery-key" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
|
||||
|
||||
const getRecoveryKeyUserState = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const headers = getRequestHeaders();
|
||||
const session = await auth.api.getSession({ headers });
|
||||
|
||||
return {
|
||||
hasCredentialPassword: session?.user ? await userHasCredentialPassword(session.user.id) : false,
|
||||
userId: session?.user.id ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||
component: DownloadRecoveryKeyPage,
|
||||
component: RouteComponent,
|
||||
errorComponent: () => <div>Failed to load recovery key</div>,
|
||||
loader: async () => getRecoveryKeyUserState(),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Download Recovery Key" },
|
||||
|
|
@ -14,3 +29,9 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
|||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { hasCredentialPassword, userId } = Route.useLoaderData();
|
||||
|
||||
return <DownloadRecoveryKeyPage hasCredentialPassword={hasCredentialPassword} userId={userId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { Layout } from "~/client/components/layout";
|
||||
|
|
@ -7,17 +7,27 @@ import { authMiddleware } from "~/middleware/auth";
|
|||
import { auth } from "~/server/lib/auth";
|
||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
||||
|
||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const headers = getRequestHeaders();
|
||||
const session = await auth.api.getSession({ headers });
|
||||
const hasUsers = await authService.hasUsers();
|
||||
const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false;
|
||||
|
||||
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
|
||||
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
|
||||
const hasSkippedRecoveryKeyDownload =
|
||||
!!session?.user && getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
|
||||
|
||||
return { user: session?.user ?? null, hasUsers, sidebarOpen };
|
||||
return {
|
||||
user: session?.user ? { ...session.user, hasCredentialPassword } : null,
|
||||
hasUsers,
|
||||
sidebarOpen,
|
||||
hasSkippedRecoveryKeyDownload,
|
||||
};
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)")({
|
||||
|
|
@ -39,6 +49,14 @@ export const Route = createFileRoute("/(dashboard)")({
|
|||
}),
|
||||
]);
|
||||
|
||||
if (
|
||||
authContext.user &&
|
||||
!authContext.user.hasDownloadedResticPassword &&
|
||||
!authContext.hasSkippedRecoveryKeyDownload
|
||||
) {
|
||||
throw redirect({ to: "/download-recovery-key" });
|
||||
}
|
||||
|
||||
return authContext;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { z } from "zod";
|
|||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
|
||||
|
||||
const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||
const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||
const restoreEventStatusSchema = z.enum(["success", "error", "cancelled"]);
|
||||
|
||||
const backupEventBaseSchema = z.object({
|
||||
scheduleId: z.string(),
|
||||
|
|
@ -15,6 +15,7 @@ const organizationScopedSchema = z.object({
|
|||
});
|
||||
|
||||
const restoreEventBaseSchema = z.object({
|
||||
restoreId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
snapshotId: z.string(),
|
||||
});
|
||||
|
|
@ -51,6 +52,8 @@ const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgress
|
|||
const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
||||
status: restoreEventStatusSchema,
|
||||
error: z.string().optional(),
|
||||
filesRestored: z.number().optional(),
|
||||
filesSkipped: z.number().optional(),
|
||||
});
|
||||
|
||||
const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export const NOTIFICATION_TYPES = {
|
|||
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||
|
||||
const headerNamePattern = /^[A-Za-z0-9-]+$/;
|
||||
const notificationHeaderSchema = z.string().refine((header) => {
|
||||
const [key, value] = header.split(":", 2);
|
||||
|
||||
return !!key && headerNamePattern.test(key.trim()) && (value?.trim().length ?? 0) > 0;
|
||||
}, "Headers must use non-empty Key: Value format with valid header names");
|
||||
|
||||
export const emailNotificationConfigSchema = z.object({
|
||||
type: z.literal("email"),
|
||||
smtpHost: z.string().min(1),
|
||||
|
|
@ -79,7 +86,7 @@ export const genericNotificationConfigSchema = z.object({
|
|||
url: z.string().min(1),
|
||||
method: z.enum(["GET", "POST"]),
|
||||
contentType: z.string().optional(),
|
||||
headers: z.array(z.string()).optional(),
|
||||
headers: z.array(notificationHeaderSchema).optional(),
|
||||
useJson: z.boolean().optional(),
|
||||
titleKey: z.string().optional(),
|
||||
messageKey: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ const serverEventPayloads = {
|
|||
"volume:unmounted": payload<{ organizationId: string; volumeName: string }>(),
|
||||
"volume:updated": payload<{ organizationId: string; volumeName: string }>(),
|
||||
"volume:status_changed": payload<{ organizationId: string; volumeName: string; status: string }>(),
|
||||
"notification:updated": payload<{
|
||||
organizationId: string;
|
||||
notificationId: number;
|
||||
notificationName: string;
|
||||
status: "healthy" | "error" | "unknown";
|
||||
}>(),
|
||||
"doctor:started": payload<{ organizationId: string; repositoryId: string; repositoryName: string }>(),
|
||||
"doctor:completed": payload<
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import type { Context, Next } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger as honoLogger } from "hono/logger";
|
||||
import { secureHeaders } from "hono/secure-headers";
|
||||
import { rateLimiter } from "hono-rate-limiter";
|
||||
import { openAPIRouteHandler } from "hono-openapi";
|
||||
|
|
@ -21,6 +21,20 @@ import { config } from "./core/config";
|
|||
import { auth } from "~/server/lib/auth";
|
||||
import { db } from "./db/db";
|
||||
|
||||
const requestLogger = async (c: Context, next: Next) => {
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
const start = performance.now();
|
||||
|
||||
logger.debug(`<-- ${method} ${path}`);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
logger.debug(`--> ${method} ${path} ${c.res.status} ${Math.round(performance.now() - start)}ms`);
|
||||
}
|
||||
};
|
||||
|
||||
const generalDescriptor = (app: Hono) =>
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
|
|
@ -49,7 +63,7 @@ export const createApp = () => {
|
|||
|
||||
if (config.environment === "production") {
|
||||
app.use(secureHeaders());
|
||||
app.use(honoLogger());
|
||||
app.use(requestLogger);
|
||||
}
|
||||
|
||||
app.use(
|
||||
|
|
@ -86,14 +100,14 @@ export const createApp = () => {
|
|||
app.get("/api/v1/docs", requireAuth, scalarDescriptor);
|
||||
|
||||
app.onError((err, c) => {
|
||||
logger.error(`${c.req.url}: ${err.message}`);
|
||||
const { status, message, details } = handleServiceError(err);
|
||||
|
||||
logger.error(`${c.req.url}: ${message}`);
|
||||
|
||||
if (err.cause instanceof Error) {
|
||||
logger.error(err.cause.message);
|
||||
}
|
||||
|
||||
const { status, message, details } = handleServiceError(err);
|
||||
|
||||
return c.json(details ? { message, details } : { message }, status as 500);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ describe("parseConfig", () => {
|
|||
ENABLE_DEV_PANEL: "true",
|
||||
SERVER_IP: "0.0.0.0",
|
||||
SERVER_IDLE_TIMEOUT: "120",
|
||||
WEBHOOK_TIMEOUT: "90",
|
||||
PORT: "8080",
|
||||
APP_VERSION: "1.2.3",
|
||||
MIGRATIONS_PATH: "/tmp/migrations",
|
||||
|
|
@ -57,6 +58,7 @@ describe("parseConfig", () => {
|
|||
environment: "development",
|
||||
serverIp: "0.0.0.0",
|
||||
serverIdleTimeout: 120,
|
||||
webhookTimeout: 90,
|
||||
resticHostname: "configured-restic-host",
|
||||
port: 8080,
|
||||
migrationsPath: "/tmp/migrations",
|
||||
|
|
@ -73,6 +75,7 @@ describe("parseConfig", () => {
|
|||
},
|
||||
provisioningPath: "/tmp/provisioning",
|
||||
allowedHosts: ["example.com", "admin.example.com", "localhost:3000"],
|
||||
webhookAllowedOrigins: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
import { repositoryLocksTable, repositoryLockWaitersTable } from "~/server/db/schema";
|
||||
import { repoMutex } from "../repository-mutex";
|
||||
|
||||
const acquireWithin = <T>(promise: Promise<T>, ms = 500) =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("RepositoryMutex", () => {
|
||||
test("should prioritize waiting exclusive locks over new shared locks", async () => {
|
||||
const repoId = "test-repo";
|
||||
|
|
@ -327,6 +338,43 @@ describe("RepositoryMutex", () => {
|
|||
expect(repoMutex.isLocked(repoB)).toBe(false);
|
||||
});
|
||||
|
||||
test("should not leave a promoted shared lock behind if that waiter aborts before observing acquisition", async () => {
|
||||
vi.useFakeTimers();
|
||||
const repoId = "shared-abort-after-promotion";
|
||||
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "holder");
|
||||
const firstSharedPromise = repoMutex.acquireShared(repoId, "shared-1");
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const controller = new AbortController();
|
||||
const secondSharedPromise = repoMutex.acquireShared(repoId, "shared-2", controller.signal);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(140);
|
||||
releaseExclusive();
|
||||
|
||||
// The first shared waiter wakes first and promotes both shared waiters.
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
const releaseShared1 = await firstSharedPromise;
|
||||
|
||||
controller.abort(new Error("abort after promotion"));
|
||||
await expect(secondSharedPromise).rejects.toThrow("abort after promotion");
|
||||
|
||||
releaseShared1();
|
||||
|
||||
const remainingLocks = await db.query.repositoryLocksTable.findMany({
|
||||
where: { repositoryId: { eq: repoId } },
|
||||
orderBy: { operation: "asc" },
|
||||
});
|
||||
|
||||
expect(remainingLocks).toEqual([]);
|
||||
} finally {
|
||||
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
|
||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("should safely handle multiple calls to the release function", async () => {
|
||||
const repoId = "idempotent-release";
|
||||
|
||||
|
|
@ -376,4 +424,100 @@ describe("RepositoryMutex", () => {
|
|||
releaseExclusive();
|
||||
expect(repoMutex.isLocked(repoId)).toBe(false);
|
||||
});
|
||||
|
||||
test("should ignore and clean expired active lock rows during acquisition", async () => {
|
||||
const repoId = "expired-active-lock";
|
||||
const expiredLockId = "expired-active-lock-row";
|
||||
const now = Date.now();
|
||||
|
||||
await db.insert(repositoryLocksTable).values({
|
||||
id: expiredLockId,
|
||||
repositoryId: repoId,
|
||||
type: "exclusive",
|
||||
operation: "stale-check",
|
||||
ownerId: "stale-owner",
|
||||
acquiredAt: now - 10_000,
|
||||
expiresAt: now - 1,
|
||||
heartbeatAt: now - 10_000,
|
||||
});
|
||||
|
||||
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
|
||||
|
||||
try {
|
||||
const expiredLock = await db.query.repositoryLocksTable.findFirst({
|
||||
where: { id: { eq: expiredLockId } },
|
||||
});
|
||||
|
||||
expect(expiredLock).toBeUndefined();
|
||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
||||
} finally {
|
||||
releaseShared();
|
||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
||||
}
|
||||
});
|
||||
|
||||
test("should ignore and clean expired waiters during acquisition", async () => {
|
||||
const repoId = "expired-waiter";
|
||||
const expiredWaiterId = "expired-waiter-row";
|
||||
const now = Date.now();
|
||||
|
||||
await db.insert(repositoryLockWaitersTable).values({
|
||||
id: expiredWaiterId,
|
||||
repositoryId: repoId,
|
||||
type: "exclusive",
|
||||
operation: "stale-exclusive",
|
||||
ownerId: "stale-owner",
|
||||
requestedAt: now - 10_000,
|
||||
expiresAt: now - 1,
|
||||
heartbeatAt: now - 10_000,
|
||||
});
|
||||
|
||||
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
|
||||
|
||||
try {
|
||||
const expiredWaiter = await db.query.repositoryLockWaitersTable.findFirst({
|
||||
where: { id: { eq: expiredWaiterId } },
|
||||
});
|
||||
|
||||
expect(expiredWaiter).toBeUndefined();
|
||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
||||
} finally {
|
||||
releaseShared();
|
||||
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
|
||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
||||
}
|
||||
});
|
||||
|
||||
test("should release only the caller lock row", async () => {
|
||||
const repoId = "release-own-row";
|
||||
const foreignLockId = "foreign-release-own-row";
|
||||
const releaseShared = await repoMutex.acquireShared(repoId, "owned-shared");
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await db.insert(repositoryLocksTable).values({
|
||||
id: foreignLockId,
|
||||
repositoryId: repoId,
|
||||
type: "shared",
|
||||
operation: "foreign-shared",
|
||||
ownerId: "foreign-owner",
|
||||
acquiredAt: now,
|
||||
expiresAt: now + 60_000,
|
||||
heartbeatAt: now,
|
||||
});
|
||||
|
||||
releaseShared();
|
||||
|
||||
const remainingLocks = await db.query.repositoryLocksTable.findMany({
|
||||
where: { repositoryId: { eq: repoId } },
|
||||
orderBy: { operation: "asc" },
|
||||
});
|
||||
|
||||
expect(remainingLocks.map((lock) => lock.operation)).toEqual(["foreign-shared"]);
|
||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
||||
} finally {
|
||||
releaseShared();
|
||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,42 +1,25 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { prettifyError, z } from "zod";
|
||||
import "dotenv/config";
|
||||
import { resolveResticHostname } from "../../../apps/agent/src/restic/hostname";
|
||||
import { buildAllowedHosts } from "../lib/auth/base-url";
|
||||
import { toMessage } from "@zerobyte/core/utils";
|
||||
|
||||
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
||||
const getResticHostname = () => {
|
||||
try {
|
||||
const mountinfo = readFileSync("/proc/self/mountinfo", "utf-8");
|
||||
const hostnameLine = mountinfo.split("\n").find((line) => line.includes(" /etc/hostname "));
|
||||
const hostname = os.hostname();
|
||||
|
||||
if (hostnameLine) {
|
||||
const containerIdMatch = hostnameLine.match(/[0-9a-f]{64}/);
|
||||
const containerId = containerIdMatch ? containerIdMatch[0] : null;
|
||||
|
||||
if (containerId?.startsWith(hostname)) {
|
||||
return "zerobyte";
|
||||
}
|
||||
|
||||
return hostname || "zerobyte";
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "zerobyte";
|
||||
};
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
SERVER_IP: z.string().default("localhost"),
|
||||
SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60),
|
||||
WEBHOOK_TIMEOUT: z.coerce.number().int().default(60),
|
||||
RESTIC_HOSTNAME: z.string().optional(),
|
||||
PORT: z.coerce.number().int().default(4096),
|
||||
MIGRATIONS_PATH: z.string().optional(),
|
||||
APP_VERSION: z.string().default("dev"),
|
||||
TRUSTED_ORIGINS: z.string().optional(),
|
||||
PORTLESS_URL: z.string().optional(),
|
||||
PORTLESS_TAILSCALE_URL: z.string().optional(),
|
||||
TRUST_PROXY: z.string().default("false"),
|
||||
DISABLE_RATE_LIMITING: z.string().default("false"),
|
||||
APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()),
|
||||
|
|
@ -44,12 +27,28 @@ const envSchema = z
|
|||
BASE_URL: z.string(),
|
||||
ENABLE_DEV_PANEL: z.string().default("false"),
|
||||
ENABLE_LOCAL_AGENT: z.string().default("false"),
|
||||
WEBHOOK_ALLOWED_ORIGINS: z.string().optional(),
|
||||
PROVISIONING_PATH: z.string().optional(),
|
||||
})
|
||||
.transform((s, ctx) => {
|
||||
const baseUrl = unquote(s.BASE_URL);
|
||||
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
|
||||
const authOrigins = [baseUrl, ...trustedOrigins];
|
||||
let baseUrl = unquote(s.BASE_URL);
|
||||
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
|
||||
|
||||
if (s.NODE_ENV === "development") {
|
||||
if (s.PORTLESS_URL) {
|
||||
trustedOrigins.push(unquote(s.PORTLESS_URL));
|
||||
}
|
||||
|
||||
if (s.PORTLESS_TAILSCALE_URL) {
|
||||
baseUrl = unquote(s.PORTLESS_TAILSCALE_URL);
|
||||
trustedOrigins.push(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
trustedOrigins.push(baseUrl);
|
||||
const uniqueTrustedOrigins = Array.from(new Set(trustedOrigins));
|
||||
const webhookAllowedOrigins = s.WEBHOOK_ALLOWED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
|
||||
const authOrigins = [baseUrl, ...uniqueTrustedOrigins];
|
||||
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
||||
let appSecret = s.APP_SECRET;
|
||||
|
||||
|
|
@ -120,11 +119,12 @@ const envSchema = z
|
|||
environment: s.NODE_ENV,
|
||||
serverIp: s.SERVER_IP,
|
||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
||||
webhookTimeout: s.WEBHOOK_TIMEOUT,
|
||||
resticHostname: s.RESTIC_HOSTNAME || resolveResticHostname(),
|
||||
port: s.PORT,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
appVersion: s.APP_VERSION,
|
||||
trustedOrigins: trustedOrigins,
|
||||
trustedOrigins: uniqueTrustedOrigins,
|
||||
trustProxy: s.TRUST_PROXY === "true",
|
||||
appSecret: appSecret ?? "",
|
||||
baseUrl,
|
||||
|
|
@ -136,6 +136,7 @@ const envSchema = z
|
|||
},
|
||||
provisioningPath: s.PROVISIONING_PATH,
|
||||
allowedHosts,
|
||||
webhookAllowedOrigins,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ export const RESTIC_CACHE_DIR = process.env.RESTIC_CACHE_DIR || "/var/lib/zeroby
|
|||
|
||||
export const DATABASE_URL = process.env.ZEROBYTE_DATABASE_URL || "/var/lib/zerobyte/data/zerobyte.db";
|
||||
export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass";
|
||||
export const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh";
|
||||
|
||||
export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone";
|
||||
export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf");
|
||||
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, SSH_KEYS_DIR, RCLONE_CONFIG_DIR, "/app"];
|
||||
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, RCLONE_CONFIG_DIR, "/app"];
|
||||
|
||||
export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { and, eq, lte } from "drizzle-orm";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../db/db";
|
||||
import {
|
||||
repositoryLocksTable,
|
||||
repositoryLockWaitersTable,
|
||||
type RepositoryLock,
|
||||
type RepositoryLockWaiter,
|
||||
} from "../db/schema";
|
||||
|
||||
type LockType = "shared" | "exclusive";
|
||||
|
||||
|
|
@ -8,292 +16,349 @@ interface LockRequest {
|
|||
operation: string;
|
||||
}
|
||||
|
||||
interface LockHolder {
|
||||
interface AcquiredLock {
|
||||
id: string;
|
||||
repositoryId: string;
|
||||
type: LockType;
|
||||
operation: string;
|
||||
acquiredAt: number;
|
||||
}
|
||||
|
||||
interface RepositoryLockState {
|
||||
sharedHolders: Map<string, LockHolder>;
|
||||
exclusiveHolder: LockHolder | null;
|
||||
waitQueue: Array<{
|
||||
type: LockType;
|
||||
operation: string;
|
||||
resolve: (lockId: string) => void;
|
||||
}>;
|
||||
}
|
||||
type RepositoryMutexTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
type HeartbeatTarget = "lock" | "waiter";
|
||||
type QueueAttempt = { status: "acquired"; lock: AcquiredLock } | { status: "waiting" } | { status: "missing" };
|
||||
|
||||
const LOCK_LEASE_MS = 30_000;
|
||||
const LOCK_HEARTBEAT_MS = 5_000;
|
||||
const LOCK_POLL_MS = 250;
|
||||
const LOCK_POLL_CLEANUP_MS = 5_000;
|
||||
|
||||
class RepositoryMutex {
|
||||
private locks = new Map<string, RepositoryLockState>();
|
||||
private changeListeners = new Map<string, Set<() => void>>();
|
||||
private lockIdCounter = 0;
|
||||
|
||||
private getOrCreateState(repositoryId: string): RepositoryLockState {
|
||||
let state = this.locks.get(repositoryId);
|
||||
if (!state) {
|
||||
state = {
|
||||
sharedHolders: new Map(),
|
||||
exclusiveHolder: null,
|
||||
waitQueue: [],
|
||||
};
|
||||
this.locks.set(repositoryId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
private ownerId = `owner_${Bun.randomUUIDv7()}`;
|
||||
private heartbeatTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
private nextPollCleanupAt = 0;
|
||||
|
||||
private generateLockId(): string {
|
||||
return `lock_${++this.lockIdCounter}_${Date.now()}`;
|
||||
return `lock_${Bun.randomUUIDv7()}`;
|
||||
}
|
||||
|
||||
private cleanupStateIfEmpty(repositoryId: string): void {
|
||||
const state = this.locks.get(repositoryId);
|
||||
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
|
||||
this.locks.delete(repositoryId);
|
||||
}
|
||||
private abortReason(signal: AbortSignal) {
|
||||
return signal.reason || new Error("Operation aborted");
|
||||
}
|
||||
|
||||
private notifyChange(repositoryId: string): void {
|
||||
const listeners = this.changeListeners.get(repositoryId);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private canAcquireImmediately(state: RepositoryLockState | undefined, type: LockType): boolean {
|
||||
if (!state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "shared") {
|
||||
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
|
||||
return !state.exclusiveHolder && !hasExclusiveInQueue;
|
||||
}
|
||||
|
||||
return !state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0;
|
||||
}
|
||||
|
||||
private waitForChange(repositoryIds: string[], signal?: AbortSignal) {
|
||||
private throwIfAborted(signal?: AbortSignal) {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
throw this.abortReason(signal);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseIfAborted(releaseLock: () => void, signal?: AbortSignal) {
|
||||
if (!signal?.aborted) return;
|
||||
releaseLock();
|
||||
throw this.abortReason(signal);
|
||||
}
|
||||
|
||||
private waitForPoll(signal?: AbortSignal) {
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const uniqueRepositoryIds = [...new Set(repositoryIds)];
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => settle(resolve), LOCK_POLL_MS);
|
||||
|
||||
const onAbort = () => {
|
||||
settle(() => reject(this.abortReason(signal!)));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
|
||||
settled = true;
|
||||
for (const cleanup of cleanupCallbacks) {
|
||||
cleanup();
|
||||
}
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
|
||||
for (const repositoryId of uniqueRepositoryIds) {
|
||||
let listeners = this.changeListeners.get(repositoryId);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
this.changeListeners.set(repositoryId, listeners);
|
||||
}
|
||||
|
||||
const listener = () => settle(resolve);
|
||||
listeners.add(listener);
|
||||
|
||||
cleanupCallbacks.push(() => {
|
||||
const currentListeners = this.changeListeners.get(repositoryId);
|
||||
if (!currentListeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentListeners.delete(listener);
|
||||
if (currentListeners.size === 0) {
|
||||
this.changeListeners.delete(repositoryId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
settle(() => reject(signal.reason || new Error("Operation aborted")));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort);
|
||||
cleanupCallbacks.push(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
private tryAcquireMany(requests: LockRequest[]) {
|
||||
for (const request of requests) {
|
||||
if (!this.canAcquireImmediately(this.locks.get(request.repositoryId), request.type)) {
|
||||
return null;
|
||||
}
|
||||
private cleanupExpired(tx: RepositoryMutexTransaction, now: number) {
|
||||
tx.delete(repositoryLocksTable).where(lte(repositoryLocksTable.expiresAt, now)).run();
|
||||
tx.delete(repositoryLockWaitersTable).where(lte(repositoryLockWaitersTable.expiresAt, now)).run();
|
||||
}
|
||||
|
||||
private cleanupExpiredDuringPolling(tx: RepositoryMutexTransaction, now: number) {
|
||||
if (now < this.nextPollCleanupAt) return;
|
||||
|
||||
this.cleanupExpired(tx, now);
|
||||
this.nextPollCleanupAt = now + LOCK_POLL_CLEANUP_MS;
|
||||
}
|
||||
|
||||
private getActiveLocks(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
|
||||
return tx.query.repositoryLocksTable
|
||||
.findMany({
|
||||
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
|
||||
orderBy: { acquiredAt: "asc", id: "asc" },
|
||||
})
|
||||
.sync();
|
||||
}
|
||||
|
||||
private getWaiters(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
|
||||
return tx.query.repositoryLockWaitersTable
|
||||
.findMany({
|
||||
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
|
||||
orderBy: { requestedAt: "asc", id: "asc" },
|
||||
})
|
||||
.sync();
|
||||
}
|
||||
|
||||
private getActiveLockById(tx: RepositoryMutexTransaction, lockId: string, now: number) {
|
||||
return tx.query.repositoryLocksTable
|
||||
.findFirst({ where: { AND: [{ id: { eq: lockId } }, { expiresAt: { gt: now } }] } })
|
||||
.sync();
|
||||
}
|
||||
|
||||
private getWaiterById(tx: RepositoryMutexTransaction, waiterId: string, now: number) {
|
||||
return tx.query.repositoryLockWaitersTable
|
||||
.findFirst({ where: { AND: [{ id: { eq: waiterId } }, { expiresAt: { gt: now } }] } })
|
||||
.sync();
|
||||
}
|
||||
|
||||
private canAcquireImmediately(type: LockType, activeLocks: RepositoryLock[], waiters: RepositoryLockWaiter[]) {
|
||||
if (type === "shared") {
|
||||
return (
|
||||
!activeLocks.some((lock) => lock.type === "exclusive") &&
|
||||
!waiters.some((waiter) => waiter.type === "exclusive")
|
||||
);
|
||||
}
|
||||
|
||||
const releases = requests.map((request) => {
|
||||
const state = this.getOrCreateState(request.repositoryId);
|
||||
const lockId = this.generateLockId();
|
||||
return activeLocks.length === 0 && waiters.length === 0;
|
||||
}
|
||||
|
||||
if (request.type === "shared") {
|
||||
state.sharedHolders.set(lockId, {
|
||||
id: lockId,
|
||||
operation: request.operation,
|
||||
acquiredAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
state.exclusiveHolder = {
|
||||
id: lockId,
|
||||
operation: request.operation,
|
||||
acquiredAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return this.createRelease(request.type, request.repositoryId, lockId);
|
||||
});
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
|
||||
released = true;
|
||||
for (const release of releases.toReversed()) {
|
||||
release();
|
||||
}
|
||||
private insertLock(
|
||||
tx: RepositoryMutexTransaction,
|
||||
request: LockRequest & { id: string; ownerId: string },
|
||||
now: number,
|
||||
) {
|
||||
const lock = {
|
||||
id: request.id,
|
||||
repositoryId: request.repositoryId,
|
||||
type: request.type,
|
||||
operation: request.operation,
|
||||
ownerId: request.ownerId,
|
||||
acquiredAt: now,
|
||||
expiresAt: now + LOCK_LEASE_MS,
|
||||
heartbeatAt: now,
|
||||
};
|
||||
|
||||
tx.insert(repositoryLocksTable).values(lock).run();
|
||||
|
||||
return lock;
|
||||
}
|
||||
|
||||
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
}
|
||||
private tryAcquireManyRows(requests: LockRequest[]) {
|
||||
const now = Date.now();
|
||||
|
||||
const state = this.getOrCreateState(repositoryId);
|
||||
return db.transaction((tx) => {
|
||||
this.cleanupExpired(tx, now);
|
||||
|
||||
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
|
||||
for (const request of requests) {
|
||||
const activeLocks = this.getActiveLocks(tx, request.repositoryId, now);
|
||||
const waiters = this.getWaiters(tx, request.repositoryId, now);
|
||||
|
||||
if (!state.exclusiveHolder && !hasExclusiveInQueue) {
|
||||
const lockId = this.generateLockId();
|
||||
state.sharedHolders.set(lockId, {
|
||||
id: lockId,
|
||||
operation,
|
||||
acquiredAt: Date.now(),
|
||||
});
|
||||
return () => this.releaseShared(repositoryId, lockId);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation} (exclusive held by: ${state.exclusiveHolder?.operation ?? "none"}, queue: ${state.waitQueue.length})`,
|
||||
);
|
||||
|
||||
let onAbort: () => void = () => {};
|
||||
let lockId: string | undefined;
|
||||
try {
|
||||
lockId = await new Promise<string>((resolve, reject) => {
|
||||
const waiter = { type: "shared" as const, operation, resolve };
|
||||
state.waitQueue.push(waiter);
|
||||
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
const index = state.waitQueue.indexOf(waiter);
|
||||
if (index !== -1) {
|
||||
state.waitQueue.splice(index, 1);
|
||||
this.cleanupStateIfEmpty(repositoryId);
|
||||
this.notifyChange(repositoryId);
|
||||
reject(signal.reason || new Error("Operation aborted"));
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", onAbort);
|
||||
if (!this.canAcquireImmediately(request.type, activeLocks, waiters)) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
if (lockId) {
|
||||
this.releaseShared(repositoryId, lockId);
|
||||
}
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
}
|
||||
|
||||
return this.createRelease("shared", repositoryId, lockId!);
|
||||
return requests.map((request) =>
|
||||
this.insertLock(tx, { ...request, id: this.generateLockId(), ownerId: this.ownerId }, now),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
private tryAcquireImmediately(request: LockRequest, signal?: AbortSignal) {
|
||||
const locks = this.tryAcquireManyRows([request]);
|
||||
if (!locks || locks.length === 0) return null;
|
||||
|
||||
const [lock] = locks;
|
||||
const releaseLock = this.createRelease(lock);
|
||||
this.releaseIfAborted(releaseLock, signal);
|
||||
|
||||
return releaseLock;
|
||||
}
|
||||
|
||||
private createWaiter(request: LockRequest, waiterId: string) {
|
||||
const now = Date.now();
|
||||
|
||||
db.transaction((tx) => {
|
||||
this.cleanupExpired(tx, now);
|
||||
tx.insert(repositoryLockWaitersTable)
|
||||
.values({
|
||||
id: waiterId,
|
||||
repositoryId: request.repositoryId,
|
||||
type: request.type,
|
||||
operation: request.operation,
|
||||
ownerId: this.ownerId,
|
||||
requestedAt: now,
|
||||
expiresAt: now + LOCK_LEASE_MS,
|
||||
heartbeatAt: now,
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
private deleteWaiter(waiterId: string) {
|
||||
db.delete(repositoryLockWaitersTable)
|
||||
.where(
|
||||
and(eq(repositoryLockWaitersTable.id, waiterId), eq(repositoryLockWaitersTable.ownerId, this.ownerId)),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
private deleteWaiterRow(tx: RepositoryMutexTransaction, waiterId: string): void {
|
||||
tx.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.id, waiterId)).run();
|
||||
}
|
||||
|
||||
private promoteWaiter(tx: RepositoryMutexTransaction, waiter: RepositoryLockWaiter, now: number) {
|
||||
this.deleteWaiterRow(tx, waiter.id);
|
||||
return this.insertLock(tx, { ...waiter, id: waiter.id }, now);
|
||||
}
|
||||
|
||||
private getLeadingSharedWaiters(waiters: RepositoryLockWaiter[]) {
|
||||
const leadingSharedWaiters: RepositoryLockWaiter[] = [];
|
||||
for (const waiter of waiters) {
|
||||
if (waiter.type === "exclusive") break;
|
||||
|
||||
leadingSharedWaiters.push(waiter);
|
||||
}
|
||||
|
||||
const state = this.getOrCreateState(repositoryId);
|
||||
return leadingSharedWaiters;
|
||||
}
|
||||
|
||||
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
|
||||
const lockId = this.generateLockId();
|
||||
state.exclusiveHolder = {
|
||||
id: lockId,
|
||||
operation,
|
||||
acquiredAt: Date.now(),
|
||||
};
|
||||
return () => this.releaseExclusive(repositoryId, lockId);
|
||||
}
|
||||
private tryPromoteWaiter(waiterId: string): QueueAttempt {
|
||||
const now = Date.now();
|
||||
|
||||
logger.debug(
|
||||
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
|
||||
);
|
||||
return db.transaction((tx) => {
|
||||
this.cleanupExpiredDuringPolling(tx, now);
|
||||
|
||||
let onAbort: () => void = () => {};
|
||||
let lockId: string | undefined;
|
||||
try {
|
||||
lockId = await new Promise<string>((resolve, reject) => {
|
||||
const waiter = { type: "exclusive" as const, operation, resolve };
|
||||
state.waitQueue.push(waiter);
|
||||
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
const index = state.waitQueue.indexOf(waiter);
|
||||
if (index !== -1) {
|
||||
state.waitQueue.splice(index, 1);
|
||||
this.cleanupStateIfEmpty(repositoryId);
|
||||
this.notifyChange(repositoryId);
|
||||
reject(signal.reason || new Error("Operation aborted"));
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
if (lockId) {
|
||||
this.releaseExclusive(repositoryId, lockId);
|
||||
const activeLock = this.getActiveLockById(tx, waiterId, now);
|
||||
if (activeLock) {
|
||||
return { status: "acquired", lock: activeLock };
|
||||
}
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
|
||||
const waiter = this.getWaiterById(tx, waiterId, now);
|
||||
if (!waiter) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
const activeLocks = this.getActiveLocks(tx, waiter.repositoryId, now);
|
||||
const waiters = this.getWaiters(tx, waiter.repositoryId, now);
|
||||
|
||||
if (waiter.type === "exclusive") {
|
||||
if (activeLocks.length > 0 || waiters[0]?.id !== waiter.id) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
|
||||
return { status: "acquired", lock: this.promoteWaiter(tx, waiter, now) };
|
||||
}
|
||||
|
||||
if (activeLocks.some((lock) => lock.type === "exclusive")) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
|
||||
const leadingSharedWaiters = this.getLeadingSharedWaiters(waiters);
|
||||
if (!leadingSharedWaiters.some((queuedWaiter) => queuedWaiter.id === waiter.id)) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
|
||||
let acquiredLock: AcquiredLock | null = null;
|
||||
for (const sharedWaiter of leadingSharedWaiters) {
|
||||
const lock = this.promoteWaiter(tx, sharedWaiter, now);
|
||||
|
||||
if (sharedWaiter.id === waiter.id) {
|
||||
acquiredLock = lock;
|
||||
}
|
||||
}
|
||||
|
||||
if (!acquiredLock) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
|
||||
return { status: "acquired", lock: acquiredLock };
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForQueuedLock(request: LockRequest, signal?: AbortSignal) {
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
const waiterId = this.generateLockId();
|
||||
this.createWaiter(request, waiterId);
|
||||
this.startHeartbeat("waiter", waiterId);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
const attempt = this.tryPromoteWaiter(waiterId);
|
||||
if (attempt.status === "acquired") {
|
||||
this.stopHeartbeat(waiterId);
|
||||
const releaseLock = this.createRelease(attempt.lock);
|
||||
this.releaseIfAborted(releaseLock, signal);
|
||||
|
||||
return releaseLock;
|
||||
}
|
||||
|
||||
if (attempt.status === "missing") {
|
||||
this.createWaiter(request, waiterId);
|
||||
this.startHeartbeat("waiter", waiterId);
|
||||
}
|
||||
|
||||
await this.waitForPoll(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
this.stopHeartbeat(waiterId);
|
||||
this.deleteWaiter(waiterId);
|
||||
this.release({ id: waiterId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal) {
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
const request: LockRequest = { repositoryId, type: "shared", operation };
|
||||
const releaseLock = this.tryAcquireImmediately(request, signal);
|
||||
if (releaseLock) {
|
||||
return releaseLock;
|
||||
}
|
||||
|
||||
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
|
||||
logger.debug(`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation}`);
|
||||
return await this.waitForQueuedLock(request, signal);
|
||||
}
|
||||
|
||||
return this.createRelease("exclusive", repositoryId, lockId!);
|
||||
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal) {
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
const request: LockRequest = { repositoryId, type: "exclusive", operation };
|
||||
const releaseLock = this.tryAcquireImmediately(request, signal);
|
||||
if (releaseLock) {
|
||||
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
|
||||
return releaseLock;
|
||||
}
|
||||
|
||||
logger.debug(`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation}`);
|
||||
const queuedReleaseLock = await this.waitForQueuedLock(request, signal);
|
||||
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
|
||||
return queuedReleaseLock;
|
||||
}
|
||||
|
||||
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
}
|
||||
this.throwIfAborted(signal);
|
||||
|
||||
if (requests.length === 0) {
|
||||
return () => {};
|
||||
|
|
@ -309,119 +374,123 @@ class RepositoryMutex {
|
|||
|
||||
const sortedRequests = [...requests].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
|
||||
while (true) {
|
||||
const releaseLocks = this.tryAcquireMany(sortedRequests);
|
||||
if (releaseLocks) {
|
||||
const locks = this.tryAcquireManyRows(sortedRequests);
|
||||
if (locks) {
|
||||
const releaseLocks = this.createReleaseMany(locks);
|
||||
this.releaseIfAborted(releaseLocks, signal);
|
||||
|
||||
return releaseLocks;
|
||||
}
|
||||
|
||||
await this.waitForChange(
|
||||
sortedRequests.map((request) => request.repositoryId),
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason || new Error("Operation aborted");
|
||||
}
|
||||
await this.waitForPoll(signal);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseShared(repositoryId: string, lockId: string): void {
|
||||
const state = this.locks.get(repositoryId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
isLocked(repositoryId: string) {
|
||||
const now = Date.now();
|
||||
|
||||
const holder = state.sharedHolders.get(lockId);
|
||||
if (!holder) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.sharedHolders.delete(lockId);
|
||||
const duration = Date.now() - holder.acquiredAt;
|
||||
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
|
||||
|
||||
this.processWaitQueue(repositoryId);
|
||||
this.cleanupStateIfEmpty(repositoryId);
|
||||
this.notifyChange(repositoryId);
|
||||
return db.transaction((tx) => {
|
||||
this.cleanupExpired(tx, now);
|
||||
return this.getActiveLocks(tx, repositoryId, now).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private releaseExclusive(repositoryId: string, lockId: string): void {
|
||||
const state = this.locks.get(repositoryId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
|
||||
logger.debug(
|
||||
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
|
||||
);
|
||||
state.exclusiveHolder = null;
|
||||
|
||||
this.processWaitQueue(repositoryId);
|
||||
this.cleanupStateIfEmpty(repositoryId);
|
||||
this.notifyChange(repositoryId);
|
||||
}
|
||||
|
||||
private processWaitQueue(repositoryId: string): void {
|
||||
const state = this.locks.get(repositoryId);
|
||||
if (!state || state.waitQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.exclusiveHolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstWaiter = state.waitQueue[0];
|
||||
|
||||
if (firstWaiter.type === "exclusive") {
|
||||
if (state.sharedHolders.size === 0) {
|
||||
state.waitQueue.shift();
|
||||
const lockId = this.generateLockId();
|
||||
state.exclusiveHolder = {
|
||||
id: lockId,
|
||||
operation: firstWaiter.operation,
|
||||
acquiredAt: Date.now(),
|
||||
};
|
||||
firstWaiter.resolve(lockId);
|
||||
}
|
||||
} else {
|
||||
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
|
||||
const waiter = state.waitQueue.shift();
|
||||
if (!waiter) break;
|
||||
const lockId = this.generateLockId();
|
||||
state.sharedHolders.set(lockId, {
|
||||
id: lockId,
|
||||
operation: waiter.operation,
|
||||
acquiredAt: Date.now(),
|
||||
});
|
||||
waiter.resolve(lockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLocked(repositoryId: string): boolean {
|
||||
const state = this.locks.get(repositoryId);
|
||||
if (!state) return false;
|
||||
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
|
||||
}
|
||||
|
||||
private createRelease(type: LockType, repositoryId: string, lockId: string) {
|
||||
private createReleaseMany(locks: AcquiredLock[]) {
|
||||
const releases = locks.map((lock) => this.createRelease(lock));
|
||||
let released = false;
|
||||
|
||||
return () => {
|
||||
if (released) return;
|
||||
|
||||
released = true;
|
||||
if (type === "shared") {
|
||||
this.releaseShared(repositoryId, lockId);
|
||||
} else {
|
||||
this.releaseExclusive(repositoryId, lockId);
|
||||
for (const release of releases.toReversed()) {
|
||||
release();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private createRelease(lock: AcquiredLock) {
|
||||
this.startHeartbeat("lock", lock.id);
|
||||
let released = false;
|
||||
|
||||
return () => {
|
||||
if (released) return;
|
||||
|
||||
released = true;
|
||||
this.stopHeartbeat(lock.id);
|
||||
this.release(lock);
|
||||
};
|
||||
}
|
||||
|
||||
private release(lock: Pick<AcquiredLock, "id">) {
|
||||
const releasedLock = db.transaction((tx) => {
|
||||
const row = tx.query.repositoryLocksTable
|
||||
.findFirst({ where: { AND: [{ id: { eq: lock.id } }, { ownerId: { eq: this.ownerId } }] } })
|
||||
.sync();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
tx.delete(repositoryLocksTable)
|
||||
.where(and(eq(repositoryLocksTable.id, lock.id), eq(repositoryLocksTable.ownerId, this.ownerId)))
|
||||
.run();
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
if (!releasedLock) return;
|
||||
|
||||
const duration = Date.now() - releasedLock.acquiredAt;
|
||||
logger.debug(
|
||||
`[Mutex] Released ${releasedLock.type} lock for repo ${releasedLock.repositoryId}: ${releasedLock.operation} (held for ${duration}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
private startHeartbeat(target: HeartbeatTarget, lockId: string) {
|
||||
this.stopHeartbeat(lockId);
|
||||
|
||||
const heartbeat = () => {
|
||||
const now = Date.now();
|
||||
const values = { heartbeatAt: now, expiresAt: now + LOCK_LEASE_MS };
|
||||
|
||||
try {
|
||||
if (target === "lock") {
|
||||
db.update(repositoryLocksTable)
|
||||
.set(values)
|
||||
.where(and(eq(repositoryLocksTable.id, lockId), eq(repositoryLocksTable.ownerId, this.ownerId)))
|
||||
.run();
|
||||
} else {
|
||||
db.update(repositoryLockWaitersTable)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(repositoryLockWaitersTable.id, lockId),
|
||||
eq(repositoryLockWaitersTable.ownerId, this.ownerId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Mutex] Failed to heartbeat ${target} ${lockId}: ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(heartbeat, LOCK_HEARTBEAT_MS);
|
||||
if (timer && "unref" in timer) {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
this.heartbeatTimers.set(lockId, timer);
|
||||
}
|
||||
|
||||
private stopHeartbeat(lockId: string) {
|
||||
const timer = this.heartbeatTimers.get(lockId);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(timer);
|
||||
this.heartbeatTimers.delete(lockId);
|
||||
}
|
||||
}
|
||||
|
||||
export const repoMutex = new RepositoryMutex();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const runMigrations = async () => {
|
|||
migrate(db, { migrationsFolder });
|
||||
|
||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||
sqlite.run("PRAGMA busy_timeout = 5000;");
|
||||
};
|
||||
|
||||
export const runDbMigrations = () => {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const relations = defineRelations(schema, (r) => ({
|
|||
sessions: r.many.sessionsTable(),
|
||||
members: r.many.member(),
|
||||
twoFactors: r.many.twoFactor(),
|
||||
passkeys: r.many.passkey(),
|
||||
ssoProviders: r.many.ssoProvider(),
|
||||
organizations: r.many.organization({
|
||||
from: r.usersTable.id.through(r.member.userId),
|
||||
|
|
@ -95,10 +96,17 @@ export const relations = defineRelations(schema, (r) => ({
|
|||
to: r.usersTable.id,
|
||||
}),
|
||||
},
|
||||
passkey: {
|
||||
usersTable: r.one.usersTable({
|
||||
from: r.passkey.userId,
|
||||
to: r.usersTable.id,
|
||||
}),
|
||||
},
|
||||
organization: {
|
||||
users: r.many.usersTable({
|
||||
alias: "usersTable_id_organization_id_via_member",
|
||||
}),
|
||||
agents: r.many.agentsTable(),
|
||||
backupSchedules: r.many.backupSchedulesTable(),
|
||||
notificationDestinations: r.many.notificationDestinationsTable(),
|
||||
repositories: r.many.repositoriesTable(),
|
||||
|
|
@ -119,6 +127,13 @@ export const relations = defineRelations(schema, (r) => ({
|
|||
optional: false,
|
||||
}),
|
||||
},
|
||||
agentsTable: {
|
||||
organization: r.one.organization({
|
||||
from: r.agentsTable.organizationId,
|
||||
to: r.organization.id,
|
||||
optional: true,
|
||||
}),
|
||||
},
|
||||
volumesTable: {
|
||||
backupSchedules: r.many.backupSchedulesTable(),
|
||||
organization: r.one.organization({
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import type {
|
|||
DoctorResult,
|
||||
ResticStatsDto,
|
||||
} from "@zerobyte/core/restic";
|
||||
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
|
||||
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
|
||||
import type { BackendConfig, BackendStatus, BackendType } from "@zerobyte/contracts/volumes";
|
||||
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
||||
import type { ShortId } from "~/server/utils/branded";
|
||||
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
||||
|
||||
/**
|
||||
* Users Table
|
||||
|
|
@ -197,6 +199,36 @@ export const ssoProvider = sqliteTable("sso_provider", {
|
|||
.default(sql`(unixepoch() * 1000)`),
|
||||
});
|
||||
|
||||
export type AgentKind = "local" | "remote";
|
||||
export type AgentStatus = "offline" | "connecting" | "online" | "degraded";
|
||||
export type AgentCapabilities = Record<string, unknown>;
|
||||
|
||||
export const agentsTable = sqliteTable(
|
||||
"agents_table",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id").references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
kind: text("kind").$type<AgentKind>().notNull(),
|
||||
status: text("status").$type<AgentStatus>().notNull().default("offline"),
|
||||
capabilities: text("capabilities", { mode: "json" }).$type<AgentCapabilities>().notNull().default({}),
|
||||
lastSeenAt: int("last_seen_at", { mode: "number" }),
|
||||
lastReadyAt: int("last_ready_at", { mode: "number" }),
|
||||
createdAt: int("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: int("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.$onUpdate(() => Date.now())
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
},
|
||||
(table) => [
|
||||
index("agents_table_organization_id_idx").on(table.organizationId),
|
||||
index("agents_table_status_idx").on(table.status),
|
||||
],
|
||||
);
|
||||
export type Agent = typeof agentsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
* Volumes Table
|
||||
*/
|
||||
|
|
@ -222,12 +254,14 @@ export const volumesTable = sqliteTable(
|
|||
.default(sql`(unixepoch() * 1000)`),
|
||||
config: text("config", { mode: "json" }).$type<BackendConfig>().notNull(),
|
||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||
agentId: text("agent_id").notNull().default(LOCAL_AGENT_ID),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
unique().on(table.name, table.organizationId),
|
||||
index("volumes_table_agent_id_idx").on(table.agentId),
|
||||
uniqueIndex("volumes_table_org_provisioning_id_uidx").on(table.organizationId, table.provisioningId),
|
||||
],
|
||||
);
|
||||
|
|
@ -276,6 +310,90 @@ export const repositoriesTable = sqliteTable(
|
|||
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
||||
|
||||
export type RepositoryLockType = "shared" | "exclusive";
|
||||
|
||||
export const repositoryLocksTable = sqliteTable(
|
||||
"repository_locks",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
repositoryId: text("repository_id").notNull(),
|
||||
type: text("type").$type<RepositoryLockType>().notNull(),
|
||||
operation: text("operation").notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
acquiredAt: int("acquired_at", { mode: "number" }).notNull(),
|
||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
||||
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("repository_locks_repository_id_idx").on(table.repositoryId),
|
||||
index("repository_locks_expires_at_idx").on(table.expiresAt),
|
||||
index("repository_locks_owner_id_idx").on(table.ownerId),
|
||||
],
|
||||
);
|
||||
export type RepositoryLock = typeof repositoryLocksTable.$inferSelect;
|
||||
|
||||
export const repositoryLockWaitersTable = sqliteTable(
|
||||
"repository_lock_waiters",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
repositoryId: text("repository_id").notNull(),
|
||||
type: text("type").$type<RepositoryLockType>().notNull(),
|
||||
operation: text("operation").notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
requestedAt: int("requested_at", { mode: "number" }).notNull(),
|
||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
||||
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("repository_lock_waiters_repository_id_idx").on(table.repositoryId),
|
||||
index("repository_lock_waiters_expires_at_idx").on(table.expiresAt),
|
||||
index("repository_lock_waiters_owner_id_idx").on(table.ownerId),
|
||||
],
|
||||
);
|
||||
export type RepositoryLockWaiter = typeof repositoryLockWaitersTable.$inferSelect;
|
||||
|
||||
type TaskJson = Record<string, unknown>;
|
||||
|
||||
export const tasksTable = sqliteTable(
|
||||
"tasks",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(),
|
||||
status: text("status").notNull(),
|
||||
resourceType: text("resource_type").notNull(),
|
||||
resourceId: text("resource_id").notNull(),
|
||||
targetAgentId: text("target_agent_id"),
|
||||
input: text("input", { mode: "json" }).$type<TaskJson>().notNull(),
|
||||
progress: text("progress", { mode: "json" }).$type<TaskJson | null>(),
|
||||
result: text("result", { mode: "json" }).$type<TaskJson | null>(),
|
||||
error: text("error"),
|
||||
cancellationRequested: int("cancellation_requested", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: int("created_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
startedAt: int("started_at", { mode: "number" }),
|
||||
updatedAt: int("updated_at", { mode: "number" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
finishedAt: int("finished_at", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("tasks_org_kind_resource_status_idx").on(
|
||||
table.organizationId,
|
||||
table.kind,
|
||||
table.resourceType,
|
||||
table.resourceId,
|
||||
table.status,
|
||||
),
|
||||
index("tasks_org_status_updated_at_idx").on(table.organizationId, table.status, table.updatedAt),
|
||||
],
|
||||
);
|
||||
export type Task = typeof tasksTable.$inferSelect;
|
||||
export type TaskInsert = typeof tasksTable.$inferInsert;
|
||||
|
||||
/**
|
||||
* Backup Schedules Table
|
||||
*/
|
||||
|
|
@ -310,6 +428,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
|
||||
customResticParams: text("custom_restic_params", { mode: "json" }).$type<string[]>().default([]),
|
||||
backupWebhooks: text("backup_webhooks", { mode: "json" }).$type<BackupWebhooks | null>(),
|
||||
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
||||
failureRetryCount: int("failure_retry_count").notNull().default(0),
|
||||
maxRetries: int("max_retries").notNull().default(2),
|
||||
|
|
@ -335,6 +454,9 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
|
|||
id: int().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
status: text().$type<"healthy" | "error" | "unknown">().notNull().default("unknown"),
|
||||
lastChecked: int("last_checked", { mode: "number" }),
|
||||
lastError: text("last_error"),
|
||||
type: text().$type<NotificationType>().notNull(),
|
||||
config: text("config", { mode: "json" }).$type<NotificationConfig>().notNull(),
|
||||
createdAt: int("created_at", { mode: "number" })
|
||||
|
|
@ -425,6 +547,30 @@ export const twoFactor = sqliteTable(
|
|||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(true),
|
||||
},
|
||||
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const passkey = sqliteTable(
|
||||
"passkey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("public_key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credential_id").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: int("created_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
aaguid: text("aaguid"),
|
||||
},
|
||||
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
|
||||
);
|
||||
export type Passkey = typeof passkey.$inferSelect;
|
||||
|
|
|
|||
18
app/server/jobs/__tests__/cleanup-dangling.test.ts
Normal file
18
app/server/jobs/__tests__/cleanup-dangling.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { config } from "../../core/config";
|
||||
import { CleanupDanglingMountsJob } from "../cleanup-dangling";
|
||||
import * as mountinfo from "../../utils/mountinfo";
|
||||
|
||||
afterEach(() => {
|
||||
config.flags.enableLocalAgent = true;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("skips controller-local mount inspection when local volume execution is agent-owned", async () => {
|
||||
config.flags.enableLocalAgent = true;
|
||||
const readMountInfo = vi.spyOn(mountinfo, "readMountInfo");
|
||||
|
||||
await new CleanupDanglingMountsJob().run();
|
||||
|
||||
expect(readMountInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -5,14 +5,21 @@ import { volumeService } from "../modules/volumes/volume.service";
|
|||
import { readMountInfo } from "../utils/mountinfo";
|
||||
import { getVolumePath } from "../modules/volumes/helpers";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||
import { executeUnmount } from "../../../apps/agent/src/volume-host/backends/utils";
|
||||
import { toMessage } from "../utils/errors";
|
||||
import { VOLUME_MOUNT_BASE } from "../core/constants";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
import { config } from "../core/config";
|
||||
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
||||
|
||||
export class CleanupDanglingMountsJob extends Job {
|
||||
async run() {
|
||||
if (config.flags.enableLocalAgent) {
|
||||
logger.debug("Skipping controller-local dangling mount cleanup because local volume execution is agent-owned.");
|
||||
return { done: true, timestamp: new Date() };
|
||||
}
|
||||
|
||||
const organizations = await db.query.organization.findMany({});
|
||||
if (organizations.length === 0) {
|
||||
logger.warn("No organizations found; skipping dangling mount cleanup to avoid false positives.");
|
||||
|
|
@ -22,7 +29,7 @@ export class CleanupDanglingMountsJob extends Job {
|
|||
const allVolumes = [];
|
||||
for (const org of organizations) {
|
||||
const volumes = await withContext({ organizationId: org.id }, async () => volumeService.listVolumes());
|
||||
allVolumes.push(...volumes);
|
||||
allVolumes.push(...volumes.filter((volume) => volume.agentId === LOCAL_AGENT_ID));
|
||||
}
|
||||
|
||||
const allSystemMounts = await readMountInfo();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { APIError } from "better-auth/api";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { config } from "../core/config";
|
||||
import { db } from "../db/db";
|
||||
|
|
@ -27,6 +28,7 @@ export const auth = betterAuth({
|
|||
baseURL: {
|
||||
allowedHosts: config.allowedHosts,
|
||||
protocol: "auto",
|
||||
fallback: config.baseUrl,
|
||||
},
|
||||
trustedOrigins: config.trustedOrigins,
|
||||
rateLimit: {
|
||||
|
|
@ -169,6 +171,26 @@ export const auth = betterAuth({
|
|||
amount: 5,
|
||||
},
|
||||
}),
|
||||
passkey({
|
||||
rpID: new URL(config.baseUrl).hostname,
|
||||
rpName: "Zerobyte",
|
||||
authenticatorSelection: {
|
||||
userVerification: "required",
|
||||
residentKey: "required",
|
||||
},
|
||||
authentication: {
|
||||
afterVerification: async ({ verification }) => {
|
||||
if (verification.authenticationInfo.userVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message:
|
||||
"Your passkey was accepted, but it did not confirm your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.",
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
tanstackStartCookies(),
|
||||
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
|
||||
],
|
||||
|
|
|
|||
6
app/server/lib/functions/login-options.ts
Normal file
6
app/server/lib/functions/login-options.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
|
||||
|
||||
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
|
||||
hasPasskeySignIn: await hasActivePasskeyUser(),
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue